From 8411055f58d9b619033cdeeb11eed81401aa1079 Mon Sep 17 00:00:00 2001 From: Sutisna Date: Fri, 7 Aug 2026 08:34:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(tugasna):=20sawala=20tugasna=20canvas=20?= =?UTF-8?q?=E2=80=94=20project=20documents=20as=20markdown=20(TUG-176,=20M?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the loop the storage format was chosen for: a canvas can be pulled to a .md file, edited with ordinary tools or read by a coding agent as a specification, and pushed back. sawala tugasna canvas pull -o spec.md sawala tugasna canvas push -f spec.md `pull` and `push` are the only commands in this file whose payload is RAW TEXT rather than JSON — deliberately not resolveInputPayload, which parses JSON. `pull` writes only the document to stdout and puts id/revision/title on stderr, so `canvas pull > spec.md` is safe in a pipeline. `push` reads the current revision first and sends it as expectedRevision, so an unattended push is still guarded against a concurrent edit rather than silently clobbering it. `--force` skips both the read and the guard. Also: list (tri-state --folder), create, get, move, link, unlink, links, history, restore, delete, and a folder sub-group over the three-level tree. Two prompts carry the distinction the whole design rests on: `unlink` says the document stays in the project, and `delete` reports how many references will be removed with it. 12 new tests (165/165 suite green), skill docs updated with a references/canvas.md covering the pull/edit/push loop, and a changeset — without one this ships nothing. --- .changeset/tugasna-canvas.md | 29 ++ .../sawala/skills/sawala-tugasna/SKILL.md | 29 +- .../sawala-tugasna/references/canvas.md | 96 +++++ packages/sawala/src/commands/tugasna.ts | 341 ++++++++++++++++++ packages/sawala/test/tugasna.test.ts | 180 +++++++++ 5 files changed, 674 insertions(+), 1 deletion(-) create mode 100644 .changeset/tugasna-canvas.md create mode 100644 packages/sawala/skills/sawala-tugasna/references/canvas.md diff --git a/.changeset/tugasna-canvas.md b/.changeset/tugasna-canvas.md new file mode 100644 index 0000000..53c9d4d --- /dev/null +++ b/.changeset/tugasna-canvas.md @@ -0,0 +1,29 @@ +--- +'@sawala/cli': minor +--- + +Add `sawala tugasna canvas` — project documents as markdown. + +A canvas is a long-form document that belongs to the project, not to a task: +tasks and boards reference one, and it outlives all of them. + +The pair that matters is `pull` / `push`. They are the only commands in +`sawala tugasna` whose payload is raw markdown rather than JSON, which is the +whole reason canvases are stored as markdown — a document can be pulled to a +`.md` file, edited with ordinary tools or read by a coding agent as a +specification, and pushed back: + + sawala tugasna canvas pull -o spec.md + sawala tugasna canvas push -f spec.md + +`pull` writes only the document to stdout, with id/revision/title on stderr, so +redirecting stdout to a file is safe. `push` reads the current revision first +and sends it as `expectedRevision`, so a concurrent edit is refused rather than +silently overwritten; `--force` skips that guard. + +Also adds `list`, `create`, `get`, `move`, `link`, `unlink`, `links`, +`history`, `restore`, `delete`, and a `folder` sub-group (`create`, `rename`, +`move`, `delete`) over the three-level folder tree. `unlink` removes a +reference and never the document — its prompt says so — while `delete` removes +the document, its history and every reference, and its prompt reports how many +references will go with it. diff --git a/packages/sawala/skills/sawala-tugasna/SKILL.md b/packages/sawala/skills/sawala-tugasna/SKILL.md index 30468ce..22510ea 100644 --- a/packages/sawala/skills/sawala-tugasna/SKILL.md +++ b/packages/sawala/skills/sawala-tugasna/SKILL.md @@ -1,6 +1,6 @@ --- name: sawala-tugasna -description: Manage Tugasna work tracking from the Sawala CLI — boards, statuses, items, comments, tags, the timeline, and the backlog. Use when creating or updating a board or task, moving an item between columns, reordering statuses, commenting on work, or working with backlog items and their placement. +description: Manage Tugasna work tracking from the Sawala CLI — boards, statuses, items, comments, tags, the timeline, the backlog, and canvases (project documents). Use when creating or updating a board or task, moving an item between columns, reordering statuses, commenting on work, working with backlog items and their placement, or reading/writing a canvas as markdown — including pulling a ticket's specification to a file and pushing it back. metadata: sawala-cli-version: "0.13.0" --- @@ -90,3 +90,30 @@ items carrying start/due dates. Backlog items are project-level and belong to no board until placed. See [the backlog model](references/backlog.md) for placement, sub-items, and the difference between `item` and `backlog` commands addressing the same item. + +## Canvases (project documents) + +A **canvas** is a long-form markdown document that belongs to the **project**, +not to a task. Tasks and boards *reference* one, and it outlives all of them — +so unlinking a canvas from a ticket never deletes the document. + + sawala tugasna canvas list [--q ] [--folder root|] [--archived] + sawala tugasna canvas create --title "Spec" + sawala tugasna canvas pull -o spec.md + sawala tugasna canvas push -f spec.md + sawala tugasna canvas link + sawala tugasna canvas unlink [-y] + sawala tugasna canvas history + sawala tugasna canvas folder create [--parent ] + +`pull` and `push` are the only commands in this group whose payload is **raw +markdown, not JSON** — that is the point of the format. `pull` writes only the +document to stdout (metadata goes to stderr), so `canvas pull > spec.md` +is safe. + +`push` reads the current revision first and sends it as `expectedRevision`, so +a concurrent edit is **refused rather than overwritten**. Pass `--force` only +when you mean "mine wins"; there is no merge. + +See [the canvas model](references/canvas.md) for the pull/edit/push loop, +many-to-many referencing, version history, and the three-level folder tree. diff --git a/packages/sawala/skills/sawala-tugasna/references/canvas.md b/packages/sawala/skills/sawala-tugasna/references/canvas.md new file mode 100644 index 0000000..2da9611 --- /dev/null +++ b/packages/sawala/skills/sawala-tugasna/references/canvas.md @@ -0,0 +1,96 @@ +# Canvas — project documents + +A **canvas** is a long-form markdown document. It belongs to the **project**, +not to a task: tasks and boards *reference* one, and it outlives all of them. + +That distinction drives everything else here. A canvas is not a bigger +`description` and not a comment — it is a document with its own id, its own +URL, its own version history, and possibly several tickets pointing at it. + +## When to use one instead of a description or a comment + +- **Description** — a paragraph. It is also rendered on every board card and + table row, so a long one degrades those surfaces. +- **Comment** — a chronological note. Fine for "deployed this", useless for + "the current acceptance criteria", because comment #17 may have superseded + comment #14 and nothing says so. +- **Canvas** — a specification, acceptance criteria, a research note, a + runbook, a meeting record. Anything that has a *current state* rather than a + history of remarks. + +## The pull / edit / push loop + +This is the reason canvases are stored as markdown rather than as rich text: + + sawala tugasna canvas pull -o spec.md + # edit spec.md with anything — an editor, a script, a coding agent + sawala tugasna canvas push -f spec.md + +`pull` writes **only the document** to stdout when `-o` is omitted; the id, +revision and title go to stderr, so `canvas pull > spec.md` is safe. + +`push` reads the file as **raw text**, not JSON. It is the one place in +`sawala tugasna` where `-f` does not mean "a JSON body". + +## Concurrent edits are refused, not merged + +Every canvas carries a `revision`. `push` reads the current revision first and +sends it as `expectedRevision`, so if someone saved while you were editing, the +push fails rather than destroying their work. + + sawala tugasna canvas push -f spec.md # guarded (default) + sawala tugasna canvas push -f spec.md --revision 7 # guarded, explicit + sawala tugasna canvas push -f spec.md --force # overwrite, unguarded + +Use `--force` only when you mean "mine wins". There is no merge. + +## Referencing from a ticket + + sawala tugasna canvas link + sawala tugasna canvas unlink [-y] + sawala tugasna canvas links # which tasks/boards point here + +Many-to-many: one architecture note can be the reference document for six +tickets without being copied six times. + +> **`unlink` does not delete the document.** It removes one reference. The +> document stays in the project and keeps every other reference to it. Only +> `canvas delete` removes a document — and that also removes its history and +> all of its references, which is why it prompts with the reference count. + +## History + + sawala tugasna canvas history + sawala tugasna canvas restore [-y] + +Saves by the same author within five minutes collapse into one history entry, +so the list reads as editing sessions rather than keystrokes. **Restoring moves +forward**: the old content is written as a *new* revision and nothing in the +history is deleted, so you can always restore back again. + +## Folders + + sawala tugasna canvas folders # flat tree as JSON + sawala tugasna canvas folder create [--parent ] + sawala tugasna canvas folder rename + sawala tugasna canvas folder move --parent | --root + sawala tugasna canvas folder delete [-y] + sawala tugasna canvas move --folder | --root + +Nesting is capped at **three levels** and a folder cannot be moved into its own +descendant; both are refused server-side (`FOLDER_TOO_DEEP`, `FOLDER_CYCLE`). + +> **Deleting a folder deletes nothing inside it.** Its documents and +> sub-folders move up one level. + +## Listing + + sawala tugasna canvas list + sawala tugasna canvas list --q spec + sawala tugasna canvas list --folder root # unfiled documents only + sawala tugasna canvas list --folder # that folder's contents + sawala tugasna canvas list --archived + +`--folder` is tri-state on purpose: **omit it** for every document in the +project, `root` for unfiled ones, an id for one folder's contents. The list +never includes document content — use `canvas get` or `canvas pull` for that. diff --git a/packages/sawala/src/commands/tugasna.ts b/packages/sawala/src/commands/tugasna.ts index 2581e47..cde272b 100644 --- a/packages/sawala/src/commands/tugasna.ts +++ b/packages/sawala/src/commands/tugasna.ts @@ -7,6 +7,7 @@ import { requireActiveProject, requireActiveProjectId, } from '@sawala/auth' +import { readFile, writeFile } from 'node:fs/promises' import { confirmOrThrow, resolveInputPayload } from '../lib/io' /** @@ -42,6 +43,14 @@ interface ItemRow { [k: string]: unknown } +interface CanvasRow { + id: string + title: string + content?: string + revision: number + [k: string]: unknown +} + interface TagRow { id: string name: string @@ -68,6 +77,31 @@ function commentsBase(projectId: string, itemId: string): string { return `${projectBase(projectId)}/items/${encodeURIComponent(itemId)}/comments` } +function canvasesBase(projectId: string): string { + return `${projectBase(projectId)}/canvases` +} + +function canvasBase(projectId: string, canvasId: string): string { + return `${canvasesBase(projectId)}/${encodeURIComponent(canvasId)}` +} + +function itemCanvasesBase(projectId: string, itemId: string): string { + return `${projectBase(projectId)}/items/${encodeURIComponent(itemId)}/canvases` +} + +function canvasFoldersBase(projectId: string): string { + return `${projectBase(projectId)}/canvas-folders` +} + +/** Read raw UTF-8 text from stdin — the markdown counterpart to readJsonInput. */ +async function readStdinText(): Promise { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : (chunk as Buffer)) + } + return Buffer.concat(chunks).toString('utf8') +} + function printJson(value: unknown): void { process.stdout.write(JSON.stringify(value, null, 2) + '\n') } @@ -574,6 +608,313 @@ export function createTugasnaCommand(): Command { tugasna.addCommand(comment) + // ── canvas (project-scoped documents) ──────────────────────────────────── + // + // A canvas is a long-form markdown document that belongs to the PROJECT, not + // to a task: tasks and boards reference one, and it outlives all of them. + // + // `pull` and `push` are the only commands in this file whose payload is RAW + // TEXT rather than JSON, and that is the whole point of storing a canvas as + // markdown — it can be pulled to a .md file, edited with ordinary tools (or + // read by a coding agent as a specification), and pushed back. + const canvas = new Command('canvas').description( + 'Project documents (list, pull/push markdown, link to tasks, history, folders).', + ) + + canvas + .command('list') + .description("List the project's documents.") + .option('--q ', 'Filter by title.') + .option('--folder ', "Folder id, or 'root' for unfiled documents.") + .option('--archived', 'Include archived documents.') + .option('--limit ', 'Maximum rows (default 50, max 200).') + .action(async (opts: { q?: string; folder?: string; archived?: boolean; limit?: string }) => { + const { ctx, projectId } = await projectContext() + const params = new URLSearchParams() + if (opts.q) params.set('q', opts.q) + if (opts.folder) params.set('folderId', opts.folder) + if (opts.archived) params.set('includeArchived', 'true') + if (opts.limit) params.set('limit', opts.limit) + const qs = params.toString() + printJson(await apiFetch(ctx, `${canvasesBase(projectId)}${qs ? `?${qs}` : ''}`)) + }) + + canvas + .command('create') + .description('Create a document. Body: { title?, content?, folderId? }.') + .option('--title ', 'Shorthand for { title }.') + .option('-f, --file <path>', "Read JSON body from path. Use '-' for stdin.") + .option('-d, --data <json>', 'Inline JSON body.') + .option('--dry-run', 'Validate and print the payload without writing.') + .action(async (opts: { title?: string; file?: string; data?: string; dryRun?: boolean }) => { + const { ctx, projectId } = await projectContext() + const body = + opts.file || opts.data + ? await resolveInputPayload(opts) + : { title: opts.title ?? 'Untitled' } + if (opts.dryRun) { + printJson({ wouldSend: { method: 'POST', body } }) + return + } + printJson(await apiFetch<unknown>(ctx, canvasesBase(projectId), { method: 'POST', body })) + }) + + canvas + .command('get <canvasId>') + .description('Print one document as JSON, including its content.') + .action(async (canvasId: string) => { + const { ctx, projectId } = await projectContext() + printJson(await apiFetch<unknown>(ctx, canvasBase(projectId, canvasId))) + }) + + canvas + .command('pull <canvasId>') + .description('Write a document to a markdown file (or stdout when -o is omitted).') + .option('-o, --out <path>', 'Destination file. Omit to write to stdout.') + .action(async (canvasId: string, opts: { out?: string }) => { + const { ctx, projectId } = await projectContext() + const doc = await apiFetch<CanvasRow>(ctx, canvasBase(projectId, canvasId)) + const content = doc.content ?? '' + if (opts.out) { + await writeFile(opts.out, content, 'utf8') + } else { + process.stdout.write(content.endsWith('\n') ? content : `${content}\n`) + } + // Metadata goes to STDERR so a pipeline capturing stdout receives only + // the document itself. + process.stderr.write(`${doc.id} rev ${doc.revision} ${doc.title}\n`) + }) + + canvas + .command('push <canvasId>') + .description('Write a markdown file back to a document (PUT).') + .option('-f, --file <path>', "Markdown file to send. Use '-' for stdin.") + .option('--title <title>', 'Also rename the document.') + .option('--revision <n>', 'Base revision. Omit to read the current one first.') + .option('--force', 'Overwrite unconditionally, ignoring concurrent edits.') + .option('--dry-run', 'Validate and print the payload without writing.') + .action( + async ( + canvasId: string, + opts: { + file?: string + title?: string + revision?: string + force?: boolean + dryRun?: boolean + }, + ) => { + const { ctx, projectId } = await projectContext() + if (!opts.file) throw new Error('Provide the markdown to push with -f <path> (or -f -).') + // Raw text, deliberately NOT resolveInputPayload — that parses JSON. + const content = opts.file === '-' ? await readStdinText() : await readFile(opts.file, 'utf8') + + let expectedRevision: number | undefined + if (!opts.force) { + if (opts.revision !== undefined) { + expectedRevision = Number(opts.revision) + } else { + // Read the current revision so an unattended push is still guarded + // against a concurrent edit rather than silently clobbering it. + const current = await apiFetch<CanvasRow>(ctx, canvasBase(projectId, canvasId)) + expectedRevision = current.revision + } + } + const body = { + content, + ...(opts.title ? { title: opts.title } : {}), + ...(expectedRevision !== undefined ? { expectedRevision } : {}), + } + if (opts.dryRun) { + printJson({ wouldSend: { method: 'PUT', body } }) + return + } + printJson( + await apiFetch<unknown>(ctx, canvasBase(projectId, canvasId), { method: 'PUT', body }), + ) + }, + ) + + canvas + .command('move <canvasId>') + .description('File a document into a folder.') + .option('--folder <folderId>', 'Target folder id.') + .option('--root', 'Move to the project root instead.') + .action(async (canvasId: string, opts: { folder?: string; root?: boolean }) => { + const { ctx, projectId } = await projectContext() + if (!opts.folder && !opts.root) throw new Error('Pass --folder <folderId> or --root.') + const body = { folderId: opts.root ? null : opts.folder } + printJson( + await apiFetch<unknown>(ctx, canvasBase(projectId, canvasId), { method: 'PATCH', body }), + ) + }) + + canvas + .command('link <itemId> <canvasId>') + .description('Reference a document from a task.') + .action(async (itemId: string, canvasId: string) => { + const { ctx, projectId } = await projectContext() + printJson( + await apiFetch<unknown>(ctx, itemCanvasesBase(projectId, itemId), { + method: 'POST', + body: { canvasId }, + }), + ) + }) + + canvas + .command('unlink <itemId> <canvasId>') + .description("Remove a task's reference. The DOCUMENT IS NOT DELETED.") + .option('-y, --yes', 'Skip the confirmation prompt.') + .action(async (itemId: string, canvasId: string, opts: { yes?: boolean }) => { + const { ctx, projectId } = await projectContext() + if (!opts.yes) { + await confirmOrThrow( + `Remove the reference to '${canvasId}' from item '${itemId}'? The document stays in the project.`, + ) + } + printJson( + await apiFetch<unknown>( + ctx, + `${itemCanvasesBase(projectId, itemId)}/${encodeURIComponent(canvasId)}`, + { method: 'DELETE' }, + ), + ) + }) + + canvas + .command('links <canvasId>') + .description('Show which tasks and boards reference a document.') + .action(async (canvasId: string) => { + const { ctx, projectId } = await projectContext() + printJson(await apiFetch<unknown>(ctx, `${canvasBase(projectId, canvasId)}/links`)) + }) + + canvas + .command('history <canvasId>') + .description('List past revisions, newest first.') + .action(async (canvasId: string) => { + const { ctx, projectId } = await projectContext() + printJson(await apiFetch<unknown>(ctx, `${canvasBase(projectId, canvasId)}/versions`)) + }) + + canvas + .command('restore <canvasId> <versionId>') + .description('Restore a past revision. Written as a NEW revision; history is kept.') + .option('-y, --yes', 'Skip the confirmation prompt.') + .action(async (canvasId: string, versionId: string, opts: { yes?: boolean }) => { + const { ctx, projectId } = await projectContext() + if (!opts.yes) { + await confirmOrThrow(`Restore version '${versionId}' of canvas '${canvasId}'?`) + } + printJson( + await apiFetch<unknown>( + ctx, + `${canvasBase(projectId, canvasId)}/versions/${encodeURIComponent(versionId)}/restore`, + { method: 'POST', body: {} }, + ), + ) + }) + + canvas + .command('delete <canvasId>') + .description('Delete a document, its history AND every reference to it.') + .option('-y, --yes', 'Skip the confirmation prompt.') + .action(async (canvasId: string, opts: { yes?: boolean }) => { + const { ctx, projectId } = await projectContext() + if (!opts.yes) { + // Say how many references would go with it — deleting a document is + // not the same as unlinking one, and the prompt should show that. + let referenced = '' + try { + const links = await apiFetch<{ items?: unknown[]; boards?: unknown[] }>( + ctx, + `${canvasBase(projectId, canvasId)}/links`, + ) + const n = (links.items?.length ?? 0) + (links.boards?.length ?? 0) + if (n > 0) referenced = ` It is referenced ${n} time(s); those references go too.` + } catch { + // A failed lookup must not block the confirmation itself. + } + await confirmOrThrow(`Delete canvas '${canvasId}' and all its history?${referenced}`) + } + printJson(await apiFetch<unknown>(ctx, canvasBase(projectId, canvasId), { method: 'DELETE' })) + }) + + canvas + .command('folders') + .description("Print the project's folder tree, flat, as JSON.") + .action(async () => { + const { ctx, projectId } = await projectContext() + printJson(await apiFetch<unknown>(ctx, canvasFoldersBase(projectId))) + }) + + const folder = new Command('folder').description('Manage canvas folders (max 3 levels).') + + folder + .command('create <name>') + .description('Create a folder.') + .option('--parent <folderId>', 'Nest inside this folder.') + .action(async (name: string, opts: { parent?: string }) => { + const { ctx, projectId } = await projectContext() + const body = { name, ...(opts.parent ? { parentId: opts.parent } : {}) } + printJson(await apiFetch<unknown>(ctx, canvasFoldersBase(projectId), { method: 'POST', body })) + }) + + folder + .command('rename <folderId> <name>') + .description('Rename a folder.') + .action(async (folderId: string, name: string) => { + const { ctx, projectId } = await projectContext() + printJson( + await apiFetch<unknown>( + ctx, + `${canvasFoldersBase(projectId)}/${encodeURIComponent(folderId)}`, + { method: 'PATCH', body: { name } }, + ), + ) + }) + + folder + .command('move <folderId>') + .description('Re-parent a folder. Refused on a cycle or past 3 levels.') + .option('--parent <folderId>', 'New parent.') + .option('--root', 'Move to the project root instead.') + .action(async (folderId: string, opts: { parent?: string; root?: boolean }) => { + const { ctx, projectId } = await projectContext() + if (!opts.parent && !opts.root) throw new Error('Pass --parent <folderId> or --root.') + printJson( + await apiFetch<unknown>( + ctx, + `${canvasFoldersBase(projectId)}/${encodeURIComponent(folderId)}`, + { method: 'PATCH', body: { parentId: opts.root ? null : opts.parent } }, + ), + ) + }) + + folder + .command('delete <folderId>') + .description('Delete a folder. Its contents MOVE UP one level; nothing is deleted.') + .option('-y, --yes', 'Skip the confirmation prompt.') + .action(async (folderId: string, opts: { yes?: boolean }) => { + const { ctx, projectId } = await projectContext() + if (!opts.yes) { + await confirmOrThrow( + `Delete folder '${folderId}'? Documents and sub-folders inside it move up one level; nothing is deleted.`, + ) + } + printJson( + await apiFetch<unknown>( + ctx, + `${canvasFoldersBase(projectId)}/${encodeURIComponent(folderId)}`, + { method: 'DELETE' }, + ), + ) + }) + + canvas.addCommand(folder) + tugasna.addCommand(canvas) + // ── tags (project-scoped, read) ────────────────────────────────────────── const tag = new Command('tag').description('Read Tugasna project tags.') diff --git a/packages/sawala/test/tugasna.test.ts b/packages/sawala/test/tugasna.test.ts index 5f01a15..64ac510 100644 --- a/packages/sawala/test/tugasna.test.ts +++ b/packages/sawala/test/tugasna.test.ts @@ -375,3 +375,183 @@ describe('sawala tugasna timeline / tag', () => { expect(cap.lines.join('')).toContain('urgent') }) }) + +describe('sawala tugasna canvas', () => { + const CANVASES = `${PROJECT}/canvases` + + it('list passes q, folder, archived and limit through', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ canvases: [], total: 0 })) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', 'sawala', 'tugasna', 'canvas', 'list', + '--q', 'spec', '--folder', 'root', '--archived', '--limit', '10', + ]) + cap.restore() + const [url] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toContain(`${CANVASES}?`) + expect(url).toContain('q=spec') + expect(url).toContain('folderId=root') + expect(url).toContain('includeArchived=true') + expect(url).toContain('limit=10') + }) + + it('pull writes the document to a file and keeps stdout clean', async () => { + const fetchMock = vi.fn(async () => + jsonResponse({ id: 'cnv_1', title: 'Spec', revision: 3, content: '# Spec\n\n- one\n' }), + ) + vi.stubGlobal('fetch', fetchMock) + const out = join(tmpDir, 'spec.md') + const cap = captureStdout() + const errLines: string[] = [] + const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation((c) => { + errLines.push(typeof c === 'string' ? c : c.toString()) + return true + }) + await createProgram().parseAsync([ + 'node', 'sawala', 'tugasna', 'canvas', 'pull', 'cnv_1', '-o', out, + ]) + cap.restore() + errSpy.mockRestore() + + expect(await fs.readFile(out, 'utf8')).toBe('# Spec\n\n- one\n') + // Metadata goes to stderr so a pipeline capturing stdout gets only the doc. + expect(cap.lines.join('')).toBe('') + expect(errLines.join('')).toContain('rev 3') + }) + + it('push reads the current revision and sends it as expectedRevision', async () => { + const file = join(tmpDir, 'push.md') + await fs.writeFile(file, '# Updated\n', 'utf8') + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ id: 'cnv_1', title: 'Spec', revision: 7, content: 'old' })) + .mockResolvedValueOnce(jsonResponse({ id: 'cnv_1', revision: 8 })) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', 'sawala', 'tugasna', 'canvas', 'push', 'cnv_1', '-f', file, + ]) + cap.restore() + + const [, init] = fetchMock.mock.calls[1] as unknown as [string, RequestInit] + const body = JSON.parse(String(init.body)) + expect(body.content).toBe('# Updated\n') + expect(body.expectedRevision).toBe(7) + }) + + it('push --force omits expectedRevision entirely', async () => { + const file = join(tmpDir, 'push.md') + await fs.writeFile(file, 'forced\n', 'utf8') + const fetchMock = vi.fn(async () => jsonResponse({ id: 'cnv_1', revision: 9 })) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', 'sawala', 'tugasna', 'canvas', 'push', 'cnv_1', '-f', file, '--force', + ]) + cap.restore() + + // Only ONE call: --force skips the read-current-revision round trip. + expect(fetchMock).toHaveBeenCalledTimes(1) + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + const body = JSON.parse(String(init.body)) + expect(body).not.toHaveProperty('expectedRevision') + expect(body.content).toBe('forced\n') + }) + + it('push --dry-run prints the payload and issues no write', async () => { + const file = join(tmpDir, 'push.md') + await fs.writeFile(file, 'draft\n', 'utf8') + const fetchMock = vi.fn(async () => + jsonResponse({ id: 'cnv_1', title: 'Spec', revision: 2, content: 'old' }), + ) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', 'sawala', 'tugasna', 'canvas', 'push', 'cnv_1', '-f', file, '--dry-run', + ]) + cap.restore() + + // The revision read still happens; the PUT does not. + expect(fetchMock).toHaveBeenCalledTimes(1) + const out = cap.lines.join('') + expect(out).toContain('wouldSend') + expect(out).toContain('PUT') + }) + + it('link POSTs the canvas id to the item', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ canvas: {}, link: { id: 'lnk_1' } })) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', 'sawala', 'tugasna', 'canvas', 'link', 'itm_1', 'cnv_1', + ]) + cap.restore() + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${PROJECT}/items/itm_1/canvases`) + expect(init.method).toBe('POST') + expect(JSON.parse(String(init.body))).toEqual({ canvasId: 'cnv_1' }) + }) + + it('unlink refuses without --yes when stdin is not a TTY', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ unlinked: true })) + vi.stubGlobal('fetch', fetchMock) + await expect( + createProgram().parseAsync([ + 'node', 'sawala', 'tugasna', 'canvas', 'unlink', 'itm_1', 'cnv_1', + ]), + ).rejects.toThrow(/--yes/) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('unlink --yes DELETEs the link, not the document', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ unlinked: true })) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', 'sawala', 'tugasna', 'canvas', 'unlink', 'itm_1', 'cnv_1', '--yes', + ]) + cap.restore() + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${PROJECT}/items/itm_1/canvases/cnv_1`) + expect(init.method).toBe('DELETE') + expect(cap.lines.join('')).toContain('unlinked') + }) + + it('move --root sends folderId null', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ id: 'cnv_1', folderId: null })) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', 'sawala', 'tugasna', 'canvas', 'move', 'cnv_1', '--root', + ]) + cap.restore() + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(JSON.parse(String(init.body))).toEqual({ folderId: null }) + }) + + it('folder create nests under --parent', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ id: 'fld_2' })) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', 'sawala', 'tugasna', 'canvas', 'folder', 'create', 'Drafts', '--parent', 'fld_1', + ]) + cap.restore() + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${PROJECT}/canvas-folders`) + expect(JSON.parse(String(init.body))).toEqual({ name: 'Drafts', parentId: 'fld_1' }) + }) + + it('history GETs the versions list', async () => { + const fetchMock = vi.fn(async () => jsonResponse([])) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', 'sawala', 'tugasna', 'canvas', 'history', 'cnv_1', + ]) + cap.restore() + const [url] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${PROJECT}/canvases/cnv_1/versions`) + }) +})