Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c0d25dd
Fix(tasks): list_tasks and zn task list leave cancelled tasks out of …
vishalhabib99 Oct 13, 2026
5ce41c4
Docs(help): the encryption entry names Save archive and the desktop app
adibhanna Oct 13, 2026
00c592b
Fix(cloud): a vault another device encrypted says so, on phones too
adibhanna Oct 13, 2026
a72e97c
Fix(help): the manual shows bold names and paragraphs instead of thei…
adibhanna Oct 13, 2026
bcc5259
Fix(create form): the footer describes keys the focused field does no…
uNyanda Oct 13, 2026
ef122a4
Fix(mcp): after zn connect, every MCP install reaches the server (#830)
adibhanna Oct 13, 2026
ed6fb80
Fix(onboarding): a server vault is not asked for a layout it already has
adibhanna Oct 13, 2026
504c155
Copy(mcp): the Claude integrations say which tab of the Claude app th…
adibhanna Oct 13, 2026
dd0f929
Feat(mcp): assistants reach a token-protected server without a termin…
adibhanna Oct 13, 2026
b25eadb
Merge #933: New Note Form Footer Hints Reflect The Focused Field (by …
adibhanna Oct 13, 2026
fb9c6fc
Fix(create form): follow-up to #933, the Enter hint reads the row Ent…
adibhanna Oct 13, 2026
dfaca0b
Merge #929: Fix(tasks): list_tasks and zn task list leave cancelled t…
adibhanna Oct 13, 2026
faa8f87
Fix(bridge): the Server token methods are desktop-only, so the phones…
adibhanna Oct 13, 2026
f9e219b
Feat(browse): a phone can start a note in the Browse folder it shows
adibhanna Oct 13, 2026
46ee243
Release: align desktop and shared packages at 2.69.0
adibhanna Oct 13, 2026
87c7e21
Docs(whats-new): the 2.69.0 highlights
adibhanna Oct 13, 2026
69342fa
Release(cli): bundle the verified 0.7.1 terminal release
adibhanna Oct 13, 2026
1290840
Fix(keys): app shortcuts no longer act behind What's New, Settings or…
adibhanna Oct 13, 2026
6da71c4
Fix(tasks): the calendar's keys no longer change tasks behind a dialog
adibhanna Oct 13, 2026
803ae29
Fix(settings): the phones and iPad offer only the settings and keys t…
adibhanna Oct 13, 2026
d134407
Fix(cloud): an encrypted vault shows its name after unlocking, and ph…
adibhanna Oct 13, 2026
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
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/desktop",
"productName": "ZenNotes",
"version": "2.68.0",
"version": "2.69.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down
66 changes: 66 additions & 0 deletions apps/desktop/src/cli/commands/tasks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { promises as fsp } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { createBackend } from '../backend'
import type { ParsedArgs } from '../args'
import { cmdTaskList } from './tasks'

function makeArgs(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[]

beforeAll(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'zen-task-cli-'))
root = path.join(tmpDir, 'vault')
for (const d of ['inbox', 'quick', 'archive', 'trash']) {
await fsp.mkdir(path.join(root, d), { recursive: true })
}
await fsp.writeFile(
path.join(root, 'inbox', 'todo.md'),
'- [ ] Buy milk\n- [-] Cancelled plan\n- [x] Done thing\n'
)
})

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

beforeEach(() => {
out = []
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
out.push(String(chunk))
return true
})
})

afterEach(() => {
vi.restoreAllMocks()
})

const backend = (): ReturnType<typeof createBackend> => createBackend({ kind: 'local', root })
const listed = (): string[] =>
(JSON.parse(out.join('')) as Array<{ content: string }>).map((t) => t.content)

describe('zn task list', () => {
it('leaves `[-]` cancelled tasks out of the open list (#450, #476)', async () => {
await cmdTaskList(backend(), makeArgs([['json', 'true']]))
expect(listed()).toEqual(['Buy milk'])
})

it('leaves cancelled tasks out of --unchecked', async () => {
await cmdTaskList(backend(), makeArgs([['unchecked', 'true'], ['json', 'true']]))
expect(listed()).toEqual(['Buy milk'])
})

it('still lists cancelled tasks with --all', async () => {
await cmdTaskList(backend(), makeArgs([['all', 'true'], ['json', 'true']]))
expect(listed()).toContain('Cancelled plan')
})
})
7 changes: 4 additions & 3 deletions apps/desktop/src/cli/commands/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ export async function cmdTaskList(vault: VaultBackend, args: ParsedArgs): Promis
if (!showAll) {
// A `[>]` forwarded record is history of a move; its live copy is in the
// destination note and lists on its own, so showing both doubled every
// carried task (#611 review). `--all` still surfaces the records.
if (onlyUnchecked) tasks = tasks.filter((t) => !t.checked && !t.forwarded)
else tasks = tasks.filter((t) => !t.checked && !t.waiting && !t.forwarded)
// carried task (#611 review). A `[-]` cancelled task is abandoned, so it is
// not open either (#450, #476). `--all` still surfaces both.
if (onlyUnchecked) tasks = tasks.filter((t) => !t.checked && !t.forwarded && !t.cancelled)
else tasks = tasks.filter((t) => !t.checked && !t.waiting && !t.forwarded && !t.cancelled)
}
if (tag) tasks = tasks.filter((t) => t.tags.includes(tag))

Expand Down
96 changes: 95 additions & 1 deletion apps/desktop/src/cli/vault-target.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
looksLikeServerUrl,
resolveAuthToken,
resolveDefaultTarget,
resolveServerToken,
resolveTarget,
resolveServerTarget,
resolveVaultTarget,
Expand All @@ -22,6 +23,19 @@ async function writeConfig(config: Record<string, unknown>): Promise<void> {
await fsp.writeFile(path.join(configDir, 'zennotes.config.json'), JSON.stringify(config))
}

/** credentials.toml the way `zn connect` writes it: BurntSushi/toml indents
* the table's quoted URL keys. */
async function writeCredentials(tokens: Record<string, string>): Promise<void> {
const lines = Object.entries(tokens).map(([url, token]) => ` "${url}" = "${token}"`)
await fsp.writeFile(
path.join(configDir, 'credentials.toml'),
['# Server tokens for zn, keyed by URL. Keep this file private.', '[tokens]', ...lines, ''].join(
'\n'
),
{ mode: 0o600 }
)
}

const HOME_PROFILE = {
id: 'p1',
name: 'home',
Expand Down Expand Up @@ -55,8 +69,9 @@ beforeEach(async () => {
})
})

afterEach(() => {
afterEach(async () => {
vi.unstubAllEnvs()
await fsp.rm(path.join(configDir, 'credentials.toml'), { force: true })
})

describe('looksLikeServerUrl', () => {
Expand Down Expand Up @@ -293,6 +308,85 @@ describe('the default target follows the workspace the app has open (#688)', ()
})
})

describe('the token `zn connect` saved reaches the server the app has open (#830)', () => {
const remoteApp = () =>
writeConfig({
vaultRoot: workVault,
workspaceMode: 'remote',
remoteWorkspace: { baseUrl: 'http://192.168.1.10:7878' },
remoteWorkspaceProfileId: 'p1',
remoteWorkspaceProfiles: [{ ...HOME_PROFILE, authToken: undefined }]
})

it('follows the app with the saved token when nothing else supplies one', async () => {
await remoteApp()
await writeCredentials({ 'http://192.168.1.10:7878': 'saved-token' })
expect(await resolveDefaultTarget(process.env)).toEqual({
kind: 'remote',
name: 'home',
baseUrl: 'http://192.168.1.10:7878',
authToken: 'saved-token'
})
})

it('ranks it last: --token, the environment and a legacy profile token all win', async () => {
await writeCredentials({ 'http://192.168.1.10:7878': 'saved-token' })
expect(await resolveServerToken('http://192.168.1.10:7878', 'flag', null, {})).toBe('flag')
expect(
await resolveServerToken('http://192.168.1.10:7878', undefined, null, {
ZENNOTES_REMOTE_TOKEN: 'env-token'
})
).toBe('env-token')
expect(await resolveServerToken('http://192.168.1.10:7878', undefined, 'legacy', {})).toBe(
'legacy'
)
expect(await resolveServerToken('http://192.168.1.10:7878', undefined, null, {})).toBe(
'saved-token'
)
})

it('finds the key zn wrote for the same server however the app spells the URL', async () => {
// zn keys by the URL trimmed, without trailing slashes, and lowercased.
await writeCredentials({ 'https://notes.example.com/zen': 'saved-token' })
expect(await resolveServerToken('https://Notes.Example.com/Zen/', undefined, null, {})).toBe(
'saved-token'
)
})

it('never lends a saved token to a different server', async () => {
await writeCredentials({
'http://192.168.1.10:7878': 'home-token',
'https://notes.example.com': 'other-token'
})
expect(await resolveServerToken('http://192.168.1.10:7879', undefined, null, {})).toBeNull()
expect(await resolveServerToken('https://notes.example.com:8443', undefined, null, {})).toBeNull()
expect(await resolveServerToken('http://notes.example.com', undefined, null, {})).toBeNull()
})

it('treats a missing or broken credentials file as no token, without throwing', async () => {
expect(await resolveServerToken('http://192.168.1.10:7878', undefined, null, {})).toBeNull()
await fsp.writeFile(path.join(configDir, 'credentials.toml'), 'tokens = "not a table"\n')
expect(await resolveServerToken('http://192.168.1.10:7878', undefined, null, {})).toBeNull()
await fsp.writeFile(path.join(configDir, 'credentials.toml'), '[tokens\n"http://x" = \n')
expect(await resolveServerToken('http://192.168.1.10:7878', undefined, null, {})).toBeNull()
await remoteApp()
expect(await resolveDefaultTarget(process.env)).toMatchObject({ authToken: null })
})

it('also authenticates --server and --vault when they name that server', async () => {
await writeConfig({
vaultRoot: workVault,
remoteWorkspaceProfiles: [{ ...HOME_PROFILE, authToken: undefined }]
})
await writeCredentials({ 'http://192.168.1.10:7878': 'saved-token' })
expect(await resolveServerTarget('home', undefined)).toMatchObject({ authToken: 'saved-token' })
expect(await resolveServerTarget('192.168.1.10:7878', undefined)).toMatchObject({
authToken: 'saved-token'
})
expect(await resolveVaultTarget('home', undefined)).toMatchObject({ authToken: 'saved-token' })
})
})

describe('sameVault: a vault switch is a different vault, not new credentials', () => {
const local = (root: string): VaultTarget => ({ kind: 'local', root })
const server = (baseUrl: string, name = '', authToken: string | null = null): VaultTarget => ({
Expand Down
35 changes: 28 additions & 7 deletions apps/desktop/src/cli/vault-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
readActiveWorkspaceFromConfig,
readKnownVaultsFromConfig,
readRemoteProfilesFromConfig,
readSavedServerToken,
resolveVaultRoot,
type KnownRemoteProfile
} from '../mcp/vault-ops.js'
Expand Down Expand Up @@ -77,6 +78,25 @@ export function resolveAuthToken(
return profileToken?.trim() || null
}

/**
* The token for a server already chosen by URL: `resolveAuthToken`, then the
* token `zn connect` saved for that URL (#830). A profile token here is only
* ever a legacy plaintext one, because the app keeps its own copy in the OS
* secret store, which this process cannot read; without the saved token, the
* MCP entry Settings → MCP installs could not reach a server that needs one
* even after `zn connect`. The Go zn checks the same four places in the same
* order when it follows the app. Looked up by the URL the caller already
* picked, a saved token can only authenticate that server, never choose one.
*/
export async function resolveServerToken(
baseUrl: string,
flagToken: string | undefined,
profileToken: string | null,
env: NodeJS.ProcessEnv = process.env
): Promise<string | null> {
return resolveAuthToken(flagToken, profileToken, env) ?? (await readSavedServerToken(baseUrl))
}

/** A bare URL rather than a profile name: has a scheme, or looks like
* `host:port`. Profile names are free-form, so this stays conservative. */
export function looksLikeServerUrl(value: string): boolean {
Expand Down Expand Up @@ -110,16 +130,17 @@ export async function resolveServerTarget(
kind: 'remote',
name: profile.name,
baseUrl: profile.baseUrl,
authToken: resolveAuthToken(flagToken, profile.authToken)
authToken: await resolveServerToken(profile.baseUrl, flagToken, profile.authToken)
}
}

if (looksLikeServerUrl(trimmed)) {
const baseUrl = normalizeBaseUrl(trimmed)
return {
kind: 'remote',
name: '',
baseUrl: normalizeBaseUrl(trimmed),
authToken: resolveAuthToken(flagToken, null)
baseUrl,
authToken: await resolveServerToken(baseUrl, flagToken, null)
}
}

Expand Down Expand Up @@ -161,7 +182,7 @@ export async function resolveVaultTarget(
kind: 'remote',
name: profile.name,
baseUrl: profile.baseUrl,
authToken: resolveAuthToken(flagToken, profile.authToken)
authToken: await resolveServerToken(profile.baseUrl, flagToken, profile.authToken)
}
}

Expand All @@ -174,8 +195,8 @@ export async function resolveVaultTarget(
* the desktop app has open, a connected server included (#688). This is what
* `zn mcp` falls back to without `--vault` / `--server` (#831), so an agent
* works on the vault the user is looking at; the app's own token stays in the
* OS secret store, so a server that needs one gets it from
* `ZENNOTES_REMOTE_TOKEN` (or `--token`).
* OS secret store, so a server that needs one gets it from `--token`,
* `ZENNOTES_REMOTE_TOKEN`, or the token `zn connect` saved for its URL (#830).
*/
export async function resolveDefaultTarget(
env: NodeJS.ProcessEnv = process.env,
Expand All @@ -192,7 +213,7 @@ export async function resolveDefaultTarget(
kind: 'remote',
name: active.name,
baseUrl: active.baseUrl,
authToken: resolveAuthToken(flagToken, active.authToken, env)
authToken: await resolveServerToken(active.baseUrl, flagToken, active.authToken, env)
}
}
return { kind: 'local', root: await resolveVaultRoot() }
Expand Down
51 changes: 50 additions & 1 deletion apps/desktop/src/main/cloud-sync-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
CloudSyncVault
} from '@zennotes/bridge-contract/cloud-sync'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { CLOUD_VAULT_SUPERSEDED_MESSAGE } from '@zennotes/shared-domain/cloud-vault-availability'
import { CloudServiceRequestError, createCloudSyncClient } from './cloud-sync-client'
import { DesktopCloudSyncService, type DesktopCloudSyncServiceDependencies } from './cloud-sync-service'

Expand Down Expand Up @@ -971,7 +972,8 @@ describe('deleted Cloud vault recovery (#791)', () => {
['unauthenticated', new CloudServiceRequestError('Sign in again', 401, 'UNAUTHENTICATED')],
['forbidden', new CloudServiceRequestError('Access denied', 403, 'FORBIDDEN')],
['server failure', new CloudServiceRequestError('Try later', 503, null)],
['unstructured proxy 404', new CloudServiceRequestError('Not found', 404, null)]
['unstructured proxy 404', new CloudServiceRequestError('Not found', 404, null)],
['410 without Cloud\'s code', new CloudServiceRequestError('Gone', 410, null)]
])('keeps the association and cursor after %s', async (_label, failure) => {
const { service, client, localRoot, storageDirectory } = await setup([cloudVault()])
const link = await service.link(localRoot, 'vault-1')
Expand Down Expand Up @@ -1038,3 +1040,50 @@ describe('deleted Cloud vault recovery (#791)', () => {
expect(await service.linkedVault(localRoot)).toEqual(replacement)
})
})

describe('Cloud vault replaced by an encrypted one', () => {
const cloudVault = (): CloudSyncVault => ({
id: 'vault-1', name: 'Notes', cursor: 0,
created_at: '2026-10-13T12:00:00.000Z', updated_at: '2026-10-13T12:00:00.000Z'
})
const superseded = () => new CloudServiceRequestError(
'This vault was replaced by an end-to-end encrypted vault. Update ZenNotes on this device to keep syncing it.',
410, 'VAULT_SUPERSEDED', { successor_vault_id: 'vault-2' }
)
const fingerprint = (value: string) => createHash('sha256').update(value).digest('hex')
const savedStatePath = (storage: string, root: string) => path.join(
storage, 'states', fingerprint(path.resolve(root)), fingerprint('https://zennotes.org'),
`${fingerprint('vault-1')}.json`
)

it('unlinks on the first 410 and says the vault is now encrypted, leaving local notes alone', async () => {
const { service, client, localRoot, storageDirectory } = await setup([cloudVault()])
await service.link(localRoot, 'vault-1')
await service.sync(localRoot)
const note = Buffer.from('Unsent local edit 📝\r\n')
await writeFile(path.join(localRoot, 'note.md'), note)
client.changes.mockRejectedValue(superseded())
client.manifest.mockRejectedValue(superseded())

await expect(service.sync(localRoot)).rejects.toThrow(CLOUD_VAULT_SUPERSEDED_MESSAGE)

expect(await service.linkedVault(localRoot)).toBeNull()
await expect(readFile(savedStatePath(storageDirectory, localRoot))).rejects.toMatchObject({ code: 'ENOENT' })
expect(await readFile(path.join(localRoot, 'note.md'))).toEqual(note)
expect(client.deleteVault).not.toHaveBeenCalled()
})

it('unlinks the same way when the background probe meets the 410 first', async () => {
const { service, client, localRoot } = await setup([cloudVault()])
await service.link(localRoot, 'vault-1')
await service.sync(localRoot)
client.manifest.mockRejectedValue(superseded())

await expect(service.hasRemoteChanges(localRoot)).rejects.toThrow(CLOUD_VAULT_SUPERSEDED_MESSAGE)

expect(await service.linkedVault(localRoot)).toBeNull()
client.manifest.mockClear()
expect(await service.hasRemoteChanges(localRoot)).toBe(false)
expect(client.manifest).not.toHaveBeenCalled()
})
})
Loading
Loading