-
-
Notifications
You must be signed in to change notification settings - Fork 27
feat: add canvas create command #65
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import { describe, expect, it } from 'bun:test'; | ||
| import { mkdtemp, writeFile, rm } from 'node:fs/promises'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
| import { buildCreateParams, resolveCanvasMarkdown } from './canvas'; | ||
|
|
||
| describe('buildCreateParams', () => { | ||
| it('returns empty params when nothing is provided', () => { | ||
| expect(buildCreateParams({})).toEqual({}); | ||
| }); | ||
|
|
||
| it('includes only the title when no markdown or channel', () => { | ||
| expect(buildCreateParams({ title: 'Sprint Notes' })).toEqual({ title: 'Sprint Notes' }); | ||
| }); | ||
|
|
||
| it('wraps markdown into a JSON-stringified document_content', () => { | ||
| const params = buildCreateParams({ markdown: '# Hello' }); | ||
| expect(params.document_content).toBe(JSON.stringify({ type: 'markdown', markdown: '# Hello' })); | ||
| expect(params.title).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('maps channel to channel_id', () => { | ||
| expect(buildCreateParams({ channel: 'C123' })).toEqual({ channel_id: 'C123' }); | ||
| }); | ||
|
|
||
| it('combines title, markdown, and channel', () => { | ||
| const params = buildCreateParams({ title: 'T', markdown: 'body', channel: 'C9' }); | ||
| expect(params.title).toBe('T'); | ||
| expect(params.channel_id).toBe('C9'); | ||
| expect(params.document_content).toBe(JSON.stringify({ type: 'markdown', markdown: 'body' })); | ||
| }); | ||
|
|
||
| it('omits empty-string markdown', () => { | ||
| expect(buildCreateParams({ markdown: '' })).toEqual({}); | ||
| }); | ||
| }); | ||
|
|
||
| describe('resolveCanvasMarkdown', () => { | ||
| it('returns undefined when no source is provided', async () => { | ||
| expect(await resolveCanvasMarkdown({})).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('returns inline content', async () => { | ||
| expect(await resolveCanvasMarkdown({ content: '# Inline' })).toBe('# Inline'); | ||
| }); | ||
|
|
||
| it('rejects when more than one source is provided', async () => { | ||
| await expect(resolveCanvasMarkdown({ content: 'a', file: 'b.md' })).rejects.toThrow( | ||
| 'Use only one of --content, --file, or --stdin', | ||
| ); | ||
| }); | ||
|
|
||
| it('rejects content + stdin together', async () => { | ||
| await expect(resolveCanvasMarkdown({ content: 'a', stdin: true })).rejects.toThrow( | ||
| 'Use only one of', | ||
| ); | ||
| }); | ||
|
|
||
| it('reads markdown from a file', async () => { | ||
| const dir = await mkdtemp(join(tmpdir(), 'canvas-test-')); | ||
| const path = join(dir, 'doc.md'); | ||
| await writeFile(path, '# From file\n- item'); | ||
| try { | ||
| expect(await resolveCanvasMarkdown({ file: path })).toBe('# From file\n- item'); | ||
| } finally { | ||
| await rm(dir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
|
|
||
| it('throws a clear error for a missing file', async () => { | ||
| const path = join(tmpdir(), 'canvas-test-does-not-exist-xyz.md'); | ||
| await expect(resolveCanvasMarkdown({ file: path })).rejects.toThrow(`File not found: ${path}`); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,77 @@ | ||
| import { Command } from 'commander'; | ||
| import ora from 'ora'; | ||
| import { readFile, stat } from 'node:fs/promises'; | ||
| import { getAuthenticatedClient } from '../lib/auth.ts'; | ||
| import { error, formatCanvasList, formatCanvasContent } from '../lib/formatter.ts'; | ||
| import { error, success, info, formatCanvasList, formatCanvasContent } from '../lib/formatter.ts'; | ||
| import { canvasHtmlToMarkdown, isAuthPage } from '../lib/canvas-parser.ts'; | ||
| import { readInteractiveInput } from '../lib/interactive-input.ts'; | ||
| import type { SlackCanvas, SlackUser } from '../types/index.ts'; | ||
|
|
||
| const CANVAS_ID_PATTERN = /^F[A-Z0-9]+$/i; | ||
| const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB | ||
|
|
||
| /** | ||
| * Resolve canvas markdown from exactly one source: inline --content, a --file, | ||
| * or --stdin. Returns undefined when no source is given (Slack allows creating | ||
| * an empty canvas). Throws on conflicting sources or an unreadable file. | ||
| */ | ||
| export async function resolveCanvasMarkdown(options: { | ||
| content?: string; | ||
| file?: string; | ||
| stdin?: boolean; | ||
| }): Promise<string | undefined> { | ||
| const sources = [options.content, options.file, options.stdin].filter(Boolean).length; | ||
| if (sources > 1) { | ||
| throw new Error('Use only one of --content, --file, or --stdin'); | ||
| } | ||
|
|
||
| if (options.content) return options.content; | ||
|
|
||
| if (options.file) { | ||
| const fileStats = await stat(options.file).catch((err: unknown) => { | ||
| if (err instanceof Error && 'code' in err && err.code === 'ENOENT') { | ||
| throw new Error(`File not found: ${options.file}`); | ||
| } | ||
| throw err; | ||
| }); | ||
| if (!fileStats.isFile()) { | ||
| throw new Error(`Cannot read non-file path: ${options.file}`); | ||
| } | ||
| if (fileStats.size > MAX_FILE_SIZE) { | ||
| throw new Error(`File too large: ${fileStats.size} bytes (max ${MAX_FILE_SIZE})`); | ||
| } | ||
| return await readFile(options.file, 'utf-8'); | ||
| } | ||
|
|
||
| if (options.stdin) { | ||
| const input = await readInteractiveInput({ | ||
| prompt: 'Enter canvas markdown (press Enter twice when done):', | ||
| }); | ||
| return input.trim(); | ||
| } | ||
|
|
||
| return undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Build the canvases.create request params from resolved inputs. Pure so it can | ||
| * be unit-tested without a network call. document_content is JSON-stringified | ||
| * to match how the Slack API expects nested objects over form-encoding. | ||
| */ | ||
| export function buildCreateParams(input: { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Major: tested function is not the one that runs Why: Fix: keep one builder (in the client), test that one, and remove |
||
| title?: string; | ||
| markdown?: string; | ||
| channel?: string; | ||
| }): Record<string, any> { | ||
| const params: Record<string, any> = {}; | ||
| if (input.title) params.title = input.title; | ||
| if (input.markdown) { | ||
| params.document_content = JSON.stringify({ type: 'markdown', markdown: input.markdown }); | ||
| } | ||
| if (input.channel) params.channel_id = input.channel; | ||
| return params; | ||
| } | ||
|
|
||
| export function createCanvasCommand(): Command { | ||
| const canvas = new Command('canvas') | ||
| .description('List and read Slack canvas documents'); | ||
|
|
@@ -223,5 +287,65 @@ export function createCanvasCommand(): Command { | |
| } | ||
| }); | ||
|
|
||
| // Create a canvas | ||
| canvas | ||
| .command('create') | ||
| .description('Create a new canvas') | ||
| .option('--title <title>', 'Canvas title') | ||
| .option('--content <markdown>', 'Canvas body as markdown') | ||
| .option('--file <path>', 'Read canvas markdown from a file') | ||
| .option('--stdin', 'Read canvas markdown from stdin', false) | ||
| .option('--channel <id>', 'Channel to tab the canvas into (required on free teams)') | ||
| .option('--workspace <id|name>', 'Workspace to use') | ||
| .option('--json', 'Output in JSON format', false) | ||
| .action(async (options) => { | ||
| // Resolve content before starting the spinner so prompts/errors are clean | ||
| let markdown: string | undefined; | ||
| try { | ||
| markdown = await resolveCanvasMarkdown(options); | ||
| } catch (err: any) { | ||
| error(err.message); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| const spinner = ora('Creating canvas...').start(); | ||
|
|
||
| try { | ||
| const client = await getAuthenticatedClient(options.workspace); | ||
|
|
||
| const response = await client.createCanvas({ | ||
| title: options.title, | ||
| markdown, | ||
| channel: options.channel, | ||
| }); | ||
|
|
||
| const canvasId = response.canvas_id; | ||
| if (!canvasId) { | ||
| spinner.fail('Canvas creation failed'); | ||
| error('Slack did not return a canvas ID.'); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| spinner.succeed(`Created canvas ${canvasId}`); | ||
|
|
||
| if (options.json) { | ||
| console.log(JSON.stringify({ | ||
| ok: true, | ||
| canvas_id: canvasId, | ||
| title: options.title, | ||
| channel: options.channel, | ||
| }, null, 2)); | ||
| return; | ||
| } | ||
|
|
||
| success(`Canvas created: ${canvasId}`); | ||
| info('Read it back with: slackcli canvas read ' + canvasId); | ||
| } catch (err: any) { | ||
| spinner.fail('Failed to create canvas'); | ||
| error(err.message); | ||
| process.exit(1); | ||
| } | ||
| }); | ||
|
|
||
| return canvas; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -242,3 +242,18 @@ export interface CanvasReadOptions { | |
| raw?: boolean; | ||
| workspace?: string; | ||
| } | ||
|
|
||
| export interface CanvasDocumentContent { | ||
| type: 'markdown'; | ||
| markdown: string; | ||
| } | ||
|
|
||
| export interface CanvasCreateOptions { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: unused types Why: Fix: type the action param as |
||
| title?: string; | ||
| content?: string; | ||
| file?: string; | ||
| stdin?: boolean; | ||
| channel?: string; | ||
| json?: boolean; | ||
| workspace?: string; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Major: contradictory browser-token claim
Why: this table says
canvas createwith a browser token is not supported, butCHANGELOG.md:15says it requirescanvases:write"or browser auth" and the PR body says it works with both. The code routes throughrequest()which dispatches tobrowserRequest, and only a standard token was live-tested, so the not-supported claim is unverified and inconsistent.Fix: test
canvases.createwith a browser token, then make README and CHANGELOG agree.