From 12d28d20eb639dbf96cd94ff25e59811c0dbcf27 Mon Sep 17 00:00:00 2001 From: Michael Wang Date: Fri, 25 Sep 2026 17:08:27 +0800 Subject: [PATCH] feat: add full v1 API commands via API client Refs #118 --- README.md | 87 ++++++++++++++- hackmd-cli.skill | Bin 2452 -> 2866 bytes hackmd-cli/SKILL.md | 16 ++- src/api/operations.ts | 68 ++++++++++++ src/commands/api/call.ts | 154 +++++++++++++++++++++++++++ src/commands/api/describe.ts | 15 +++ src/commands/api/operations.ts | 17 +++ src/commands/notes/create.ts | 2 +- src/commands/team-notes/create.ts | 2 +- test/integration/api.test.ts | 169 ++++++++++++++++++++++++++++++ tsconfig.json | 5 +- 11 files changed, 527 insertions(+), 8 deletions(-) create mode 100644 src/api/operations.ts create mode 100644 src/commands/api/call.ts create mode 100644 src/commands/api/describe.ts create mode 100644 src/commands/api/operations.ts create mode 100644 test/integration/api.test.ts diff --git a/README.md b/README.md index 04aa67e..988131c 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ [![License](https://img.shields.io/npm/l/@hackmd/hackmd-cli.svg)](https://github.com/hackmdio/hackmd-cli/blob/master/package.json) * [Usage](#usage) +* [Full API access](#full-api-access) * [Commands](#commands) * [Configuration](#configuration) * [License](#license) @@ -31,7 +32,7 @@ $ npm install -g @hackmd/hackmd-cli $ hackmd-cli COMMAND running command... $ hackmd-cli (--version|-v) -@hackmd/hackmd-cli/2.5.1 darwin-arm64 node-v26.0.0 +@hackmd/hackmd-cli/2.5.1 darwin-arm64 node-v24.16.0 $ hackmd-cli --help [COMMAND] USAGE $ hackmd-cli COMMAND @@ -39,6 +40,23 @@ USAGE ``` +## Full API access + +The existing `notes`, `folders`, and other commands remain unchanged. For operations without a dedicated command, use the installed API client's generated operations: + +```bash +hackmd-cli api operations +hackmd-cli api describe GetTeamNote +hackmd-cli api call GetTeamNote --path teampath=docs --path noteId=abc +hackmd-cli api call ListVersions --path noteId=abc --query limit=10 +hackmd-cli api call CreateNote --body @note.json +hackmd-cli api call UploadNoteImage --path noteId=abc --file image=@photo.png +``` + +Use repeated `--path key=value`, `--query key=value`, and `--header 'Name: value'` flags. `--body` accepts JSON text, `@file`, or `-` for stdin. For multipart image uploads, use `--file image=@path`; the file extension sets its MIME type, or use `--mime` to override it. `--include` prints HTTP status and headers before the response body. Empty 204/304 responses print no body; NDJSON is printed unchanged. Errors exit nonzero. + +`api operations` and `api describe` work offline and show what the **installed API client version** supports, not what a connected HackMD EE server necessarily supports. `api call` uses the same access token and endpoint configuration as other commands. Writes are not retried automatically. + ## Configuration ### Set access token @@ -100,6 +118,9 @@ All available configurations are listed in the table below. ## Commands +* [`hackmd-cli api call OPERATIONID`](#hackmd-cli-api-call-operationid) +* [`hackmd-cli api describe OPERATIONID`](#hackmd-cli-api-describe-operationid) +* [`hackmd-cli api operations`](#hackmd-cli-api-operations) * [`hackmd-cli autocomplete [SHELL]`](#hackmd-cli-autocomplete-shell) * [`hackmd-cli export`](#hackmd-cli-export) * [`hackmd-cli folders`](#hackmd-cli-folders) @@ -128,6 +149,66 @@ All available configurations are listed in the table below. * [`hackmd-cli version`](#hackmd-cli-version) * [`hackmd-cli whoami`](#hackmd-cli-whoami) +## `hackmd-cli api call OPERATIONID` + +Call an operation through the installed @hackmd/api/raw API client + +``` +USAGE + $ hackmd-cli api call OPERATIONID [--body ] [--file ] [--header ] [-h] [--include] + [--mime ] [--path ] [--query ] + +FLAGS + -h, --help Show CLI help. + --body= JSON value, @file, or - for stdin + --file=... Multipart binary field, e.g. image=@photo.png + --header=... Request header Name: value + --include Include HTTP status and response headers + --mime= MIME type override for --file + --path=... Path parameter key=value + --query=... Query parameter key=value + +DESCRIPTION + Call an operation through the installed @hackmd/api/raw API client + +EXAMPLES + $ hackmd-cli api call GetTeamNote --path teampath=docs --path noteId=abc + + $ hackmd-cli api call CreateNote --body @note.json + + $ hackmd-cli api call UploadNoteImage --path noteId=abc --file image=@photo.png +``` + +## `hackmd-cli api describe OPERATIONID` + +Describe one installed API client operation (offline) + +``` +USAGE + $ hackmd-cli api describe OPERATIONID [-h] + +FLAGS + -h, --help Show CLI help. + +DESCRIPTION + Describe one installed API client operation (offline) +``` + +## `hackmd-cli api operations` + +List operations supported by the installed @hackmd/api API client (offline) + +``` +USAGE + $ hackmd-cli api operations [-h] + +FLAGS + -h, --help Show CLI help. + +DESCRIPTION + List operations supported by the installed @hackmd/api API client (offline) +``` + ## `hackmd-cli autocomplete [SHELL]` display autocomplete installation instructions @@ -550,7 +631,7 @@ EXAMPLES $ hackmd-cli notes update --noteId=WNkLM6gkS0Cg2cQ8rv7bYA --tags=tag1,tag2 - cat README.md | hackmd-cli notes update --noteId=WNkLM6gkS0Cg2cQ8rv7bYA + $ cat README.md | hackmd-cli notes update --noteId=WNkLM6gkS0Cg2cQ8rv7bYA ``` ## `hackmd-cli team-folders` @@ -836,7 +917,7 @@ EXAMPLES $ hackmd-cli team-notes update --teamPath=CLI-test --noteId=WNkLM6gkS0Cg2cQ8rv7bYA --tags=tag1,tag2 - cat README.md | hackmd-cli team-notes update --teamPath=CLI-test --noteId=WNkLM6gkS0Cg2cQ8rv7bYA + $ cat README.md | hackmd-cli team-notes update --teamPath=CLI-test --noteId=WNkLM6gkS0Cg2cQ8rv7bYA ``` ## `hackmd-cli teams` diff --git a/hackmd-cli.skill b/hackmd-cli.skill index 55ff35e5f5d49e76bd6a63555bb824c52553bcd4..1d712f37f6e43879c5de12105ee085230e5c8009 100644 GIT binary patch delta 2730 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 USVDBb{}^RX2xPBF#D72i2MHS&mjD0& delta 2318 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 k6%5z>!Ippd; + path: string; + requestBody?: { + binaryFields: ReadonlyArray<{name: string; required: boolean}>; + contentTypes: readonly string[]; + required: boolean; + }; + responses: Record; +} + +export function getOperation(id: string): Operation { + if (!Object.hasOwn(operationRegistry, id)) { + throw new Error(`Unknown operation "${id}". Run "hackmd-cli api operations" to list the installed API client's operations.`) + } + + return operationRegistry[id as OperationId] +} + +export function parsePairs(values: string[], separator: string, kind: string): Record { + const result: Record = Object.create(null) + for (const value of values) { + const index = value.indexOf(separator) + if (index <= 0) throw new Error(`Invalid --${kind} value "${value}"; expected key${separator}value`) + const key = value.slice(0, index).trim() + if (!key || Object.hasOwn(result, key)) throw new Error(`Duplicate or empty --${kind} key "${key}"`) + result[key] = value.slice(index + separator.length).trim() + } + + return result +} + +export function validateParameters(operation: Operation, location: 'path' | 'query', input: Record): Record { + const parameters = operation.parameters.filter(parameter => parameter.in === location) + const allowed = new Map(parameters.map(parameter => [parameter.name, parameter])) + for (const parameter of parameters) { + if (parameter.required && !Object.hasOwn(input, parameter.name)) { + throw new Error(`Missing required --${location} ${parameter.name}=...`) + } + } + + const result: Record = {} + for (const [key, value] of Object.entries(input)) { + const parameter = allowed.get(key) + if (!parameter) throw new Error(`Unknown --${location} parameter "${key}" for ${operation.method} ${operation.path}`) + if (parameter.type === 'number' || parameter.type === 'integer') { + const number = Number(value) + if (value === '' || !Number.isFinite(number) || (parameter.type === 'integer' && !Number.isInteger(number))) { + throw new Error(`--${location} ${key} must be a ${parameter.type}`) + } + + result[key] = number + } else if (parameter.type === 'boolean') { + if (value !== 'true' && value !== 'false') throw new Error(`--${location} ${key} must be true or false`) + result[key] = value === 'true' + } else { + result[key] = value + } + } + + return result +} diff --git a/src/commands/api/call.ts b/src/commands/api/call.ts new file mode 100644 index 0000000..b64460e --- /dev/null +++ b/src/commands/api/call.ts @@ -0,0 +1,154 @@ +import type {Client} from '@hackmd/api/raw' + +import {createClient} from '@hackmd/api/raw' +import {Args, Flags, ux} from '@oclif/core' +import {readFileSync} from 'node:fs' +import {basename, extname} from 'node:path' + +import type {Operation} from '../../api/operations' + +import {getOperation, parsePairs, validateParameters} from '../../api/operations' +import HackMDCommand from '../../command' +import config from '../../config' +import {setAccessTokenConfig} from '../../utils' + +type RawResponse = {data: unknown; headers: Record; status: number} +type RawCall = (options: { + body?: unknown; + client: Client; + headers?: Record; + path?: Record; + query?: Record; + responseType?: 'text'; + throwOnError: true; +}) => Promise + +const mimeTypes: Record = { + '.avif': 'image/avif', + '.gif': 'image/gif', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', +} + +function readBody(value: string): unknown { + const content = value === '-' + ? readFileSync(process.stdin.fd, 'utf8') + : (value.startsWith('@') ? readFileSync(value.slice(1), 'utf8') : value) + try { + return JSON.parse(content) + } catch { + throw new Error('--body must contain valid JSON') + } +} + +function prepareFiles(operation: Operation, files: string[], mime: string | undefined): Record { + if (!operation.requestBody?.contentTypes.includes('multipart/form-data')) throw new Error('--file is only supported for multipart operations') + const result: Record = {} + for (const input of files) { + const [field, filepath] = input.split('=@', 2) + if (!field || !filepath || !operation.requestBody.binaryFields.some(entry => entry.name === field)) { + throw new Error(`Invalid --file "${input}"; expected a documented field such as image=@path`) + } + + if (result[field]) throw new Error(`Duplicate --file field "${field}"`) + const contentType = mime ?? mimeTypes[extname(filepath).toLowerCase()] + if (!contentType) throw new Error(`Cannot infer MIME type for ${filepath}; pass --mime`) + result[field] = new File([readFileSync(filepath)], basename(filepath), {type: contentType}) + } + + for (const field of operation.requestBody.binaryFields) { + if (field.required && !result[field.name]) throw new Error(`Missing required --file ${field.name}=@path`) + } + + return result +} + +function prepareBody(operation: Operation, body: string | undefined, file: string[] | undefined, mime: string | undefined): unknown { + const files = file ?? [] + if (body !== undefined && files.length > 0) throw new Error('Use either --body or --file, not both') + if (mime && files.length === 0) throw new Error('--mime requires --file') + if ((body !== undefined || files.length > 0) && !operation.requestBody) throw new Error(`${operation.method} ${operation.path} has no request body`) + if (operation.requestBody?.required && body === undefined && files.length === 0) throw new Error('This operation requires --body or --file') + if (files.length > 0) return prepareFiles(operation, files, mime) + + if (body !== undefined) { + if (!operation.requestBody?.contentTypes.includes('application/json')) throw new Error('--body JSON is not supported by this operation') + return readBody(body) + } +} + +function formatBody(data: unknown): string { + if (typeof data === 'string') return data + return JSON.stringify(data, null, 2) +} + +export default class CallCommand extends HackMDCommand { + static args = {operationId: Args.string({required: true})} + static description = 'Call an operation through the installed @hackmd/api/raw API client' + static examples = [ + 'hackmd-cli api call GetTeamNote --path teampath=docs --path noteId=abc', + 'hackmd-cli api call CreateNote --body @note.json', + 'hackmd-cli api call UploadNoteImage --path noteId=abc --file image=@photo.png', + ] + static flags = { + body: Flags.string({description: 'JSON value, @file, or - for stdin'}), + file: Flags.string({description: 'Multipart binary field, e.g. image=@photo.png', multiple: true}), + header: Flags.string({description: 'Request header Name: value', multiple: true}), + help: Flags.help({char: 'h'}), + include: Flags.boolean({description: 'Include HTTP status and response headers'}), + mime: Flags.string({description: 'MIME type override for --file'}), + path: Flags.string({description: 'Path parameter key=value', multiple: true}), + query: Flags.string({description: 'Query parameter key=value', multiple: true}), + } + + async run() { + const {args, flags} = await this.parse(CallCommand) + const operation = getOperation(args.operationId) + const path = validateParameters(operation, 'path', parsePairs(flags.path ?? [], '=', 'path')) + const query = validateParameters(operation, 'query', parsePairs(flags.query ?? [], '=', 'query')) + const headers = parsePairs(flags.header ?? [], ':', 'header') + const body = prepareBody(operation, flags.body, flags.file, flags.mime) + const token = config.accessToken || await ux.prompt('Enter your access token', {type: 'hide'}) + if (!token) throw new Error('An access token is required') + const client = createClient({ + auth: token, + baseURL: config.hackmdAPIEndpointURL, + validateStatus: status => (status >= 200 && status < 300) || status === 304, + }) + const ndjson = Object.values(operation.responses).some(contentTypes => contentTypes.includes('application/x-ndjson')) + try { + const response = await (operation.call as unknown as RawCall)({ + body, + client, + headers, + path, + query, + ...(ndjson ? {responseType: 'text' as const} : {}), + throwOnError: true, + }) + if (!config.accessToken) setAccessTokenConfig(token) + if (flags.include) { + this.log(`HTTP ${response.status}`) + for (const [name, value] of Object.entries(response.headers)) this.log(`${name}: ${value}`) + this.log('') + } + + const hasBody = operation.responses[String(response.status)]?.length !== 0 + if (hasBody && response.data !== undefined && response.data !== null) { + if (ndjson) process.stdout.write(formatBody(response.data)) + else this.log(formatBody(response.data)) + } + } catch (error) { + const failure = error as {message?: string; response?: RawResponse} + if (failure.response) { + const {data, status} = failure.response + this.error(`HTTP ${status}${data === undefined ? '' : `: ${formatBody(data)}`}`) + } + + this.error(failure.message ?? String(error)) + } + } +} diff --git a/src/commands/api/describe.ts b/src/commands/api/describe.ts new file mode 100644 index 0000000..1777775 --- /dev/null +++ b/src/commands/api/describe.ts @@ -0,0 +1,15 @@ +import {Args, Flags} from '@oclif/core' + +import {getOperation} from '../../api/operations' +import HackMDCommand from '../../command' + +export default class DescribeCommand extends HackMDCommand { + static args = {operationId: Args.string({required: true})} + static description = 'Describe one installed API client operation (offline)' + static flags = {help: Flags.help({char: 'h'})} + + async run() { + const {args} = await this.parse(DescribeCommand) + this.log(JSON.stringify({operationId: args.operationId, ...getOperation(args.operationId)}, null, 2)) + } +} diff --git a/src/commands/api/operations.ts b/src/commands/api/operations.ts new file mode 100644 index 0000000..1b92af1 --- /dev/null +++ b/src/commands/api/operations.ts @@ -0,0 +1,17 @@ +import {operationRegistry} from '@hackmd/api/raw' +import {Flags} from '@oclif/core' + +import HackMDCommand from '../../command' + +export default class OperationsCommand extends HackMDCommand { + static description = 'List operations supported by the installed @hackmd/api API client (offline)' + static flags = {help: Flags.help({char: 'h'})} + + async run() { + await this.parse(OperationsCommand) + this.log('Operations supported by the installed API client (not necessarily by the connected server):') + for (const [id, operation] of Object.entries(operationRegistry)) { + this.log(`${id}\t${operation.method} ${operation.path}`) + } + } +} diff --git a/src/commands/notes/create.ts b/src/commands/notes/create.ts index 132e427..11e7b80 100644 --- a/src/commands/notes/create.ts +++ b/src/commands/notes/create.ts @@ -93,7 +93,7 @@ raUuSTetT5uQbqQfLnz9lA A new note gvfz2UB5THiKABQJQnLs6Q n header: 'ID', }, tags: { - get: row => (row.tags ?? []).join(', '), + get: row => ('tags' in row ? row.tags ?? [] : []).join(', '), }, teamPath: { header: 'Team path', diff --git a/src/commands/team-notes/create.ts b/src/commands/team-notes/create.ts index db4a0af..4d94694 100644 --- a/src/commands/team-notes/create.ts +++ b/src/commands/team-notes/create.ts @@ -87,7 +87,7 @@ raUuSTetT5uQbqQfLnz9lA A new note gvfz2UB5THiKABQJQnLs6Q n header: 'ID', }, tags: { - get: row => (row.tags ?? []).join(', '), + get: row => ('tags' in row ? row.tags ?? [] : []).join(', '), }, teamPath: { header: 'Team path', diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts new file mode 100644 index 0000000..67e67b9 --- /dev/null +++ b/test/integration/api.test.ts @@ -0,0 +1,169 @@ +import {expect} from 'chai' +import {spawn} from 'node:child_process' +import {mkdtempSync, rmSync, writeFileSync} from 'node:fs' +import {createServer, IncomingMessage, ServerResponse} from 'node:http' +import {createRequire} from 'node:module' +import {tmpdir} from 'node:os' +import path from 'node:path' + +type Result = {code: null | number; stderr: string; stdout: string} + +function run(args: string[], endpoint: string, configDir: string, input?: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [path.join(process.cwd(), 'bin/run'), ...args], { + env: { + ...process.env, HMD_API_ACCESS_TOKEN: 'test-token', HMD_API_ENDPOINT_URL: endpoint, HMD_CLI_CONFIG_DIR: configDir, NODE_ENV: 'production', + }, + }) + let stdout = '' + let stderr = '' + child.stdout.on('data', chunk => { + stdout += chunk + }) + child.stderr.on('data', chunk => { + stderr += chunk + }) + child.on('error', reject) + child.on('close', code => resolve({code, stderr, stdout})) + if (input === undefined) child.stdin.end() + else child.stdin.end(input) + }) +} + +describe('API commands with local API client', () => { + const configDir = mkdtempSync(path.join(tmpdir(), 'hackmd-cli-api-')) + const requests: Array<{authorization: string | undefined; body: string; contentType: string | undefined; url: string | undefined}> = [] + let endpoint = '' + let status = 200 + let responseBody = '{}' + let contentType = 'application/json' + const server = createServer(async (request: IncomingMessage, response: ServerResponse) => { + const chunks: Buffer[] = [] + for await (const chunk of request) chunks.push(Buffer.from(chunk)) + requests.push({ + authorization: request.headers.authorization, + body: Buffer.concat(chunks).toString(), + contentType: request.headers['content-type'], + url: request.url, + }) + response.writeHead(status, {'Content-Type': contentType, ETag: '"v1"'}) + response.end(status === 204 || status === 304 ? undefined : responseBody) + }) + + before(async () => { + await new Promise(resolve => { + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Expected a local TCP address') + endpoint = `http://127.0.0.1:${address.port}/v1` + }) + + after(async () => { + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()) + }) + rmSync(configDir, {force: true, recursive: true}) + }) + + beforeEach(() => { + requests.length = 0 + status = 200 + responseBody = '{}' + contentType = 'application/json' + }) + + it('loads the CJS raw entry and lists/describes operations offline', async () => { + const raw = createRequire(path.join(process.cwd(), 'package.json'))('@hackmd/api/raw') + expect(raw.operationRegistry.GetTeamNote.call).to.be.a('function') + const list = await run(['api', 'operations'], 'http://127.0.0.1:1/v1', configDir) + expect(list.code).to.equal(0) + expect(list.stdout).to.include('installed API client') + expect(list.stdout).to.include('GetTeamNote') + const describe = await run(['api', 'describe', 'GetTeamNote'], 'http://127.0.0.1:1/v1', configDir) + expect(describe.code).to.equal(0) + const operation = JSON.parse(describe.stdout) + expect(operation.path).to.equal('/teams/{teampath}/notes/{noteId}') + expect(operation.parameters.map((parameter: {name: string}) => parameter.name)).to.include.members(['teampath', 'noteId']) + expect(requests).to.have.length(0) + }) + + it('uses token, custom endpoint, path, query and response headers', async () => { + responseBody = JSON.stringify({id: 'abc'}) + const result = await run(['api', 'call', 'GetTeamNote', '--path', 'teampath=docs', '--path', 'noteId=abc', '--include'], endpoint, configDir) + expect(result.code, result.stderr).to.equal(0) + expect(result.stdout).to.include('HTTP 200') + expect(result.stdout).to.include('"id": "abc"') + expect(requests).to.have.length(1) + expect(requests[0].url).to.equal('/v1/teams/docs/notes/abc') + expect(requests[0].authorization).to.equal('Bearer test-token') + const query = await run(['api', 'call', 'ListVersions', '--path', 'noteId=abc', '--query', 'limit=2'], endpoint, configDir) + expect(query.code, query.stderr).to.equal(0) + expect(requests[1].url).to.equal('/v1/notes/abc/versions?limit=2') + }) + + it('handles documented no-body statuses without printing the generated empty object', async () => { + status = 304 + const notModified = await run(['api', 'call', 'GetNote', '--path', 'noteId=abc', '--header', 'If-None-Match: "v1"', '--include'], endpoint, configDir) + expect(notModified.code, notModified.stderr).to.equal(0) + expect(notModified.stdout).to.include('HTTP 304') + expect(notModified.stdout).not.to.include('{}') + status = 204 + const restored = await run(['api', 'call', 'RestoreNote', '--path', 'noteId=abc'], endpoint, configDir) + expect(restored.code, restored.stderr).to.equal(0) + expect(restored.stdout).to.equal('') + status = 202 + const updated = await run(['api', 'call', 'UpdateNote', '--path', 'noteId=abc', '--body', '{"content":"updated"}'], endpoint, configDir) + expect(updated.code, updated.stderr).to.equal(0) + expect(updated.stdout).to.equal('') + }) + + it('sends JSON once and prints a 207 response', async () => { + status = 207 + responseBody = JSON.stringify({error: 'partial', note: {id: 'abc'}}) + const result = await run(['api', 'call', 'CreateNote', '--body', '{"content":"hello"}'], endpoint, configDir) + expect(result.code, result.stderr).to.equal(0) + expect(result.stdout).to.include('"partial"') + expect(requests).to.have.length(1) + expect(JSON.parse(requests[0].body)).to.deep.equal({content: 'hello'}) + const filePath = path.join(configDir, 'note.json') + writeFileSync(filePath, '{"content":"from file"}') + const fromFile = await run(['api', 'call', 'CreateNote', '--body', `@${filePath}`], endpoint, configDir) + expect(fromFile.code, fromFile.stderr).to.equal(0) + expect(JSON.parse(requests[1].body)).to.deep.equal({content: 'from file'}) + const fromStdin = await run(['api', 'call', 'CreateNote', '--body', '-'], endpoint, configDir, '{"content":"from stdin"}') + expect(fromStdin.code, fromStdin.stderr).to.equal(0) + expect(JSON.parse(requests[2].body)).to.deep.equal({content: 'from stdin'}) + }) + + it('uploads multipart with inferred MIME and supports NDJSON text', async () => { + const imagePath = path.join(configDir, 'image.png') + writeFileSync(imagePath, Buffer.from([0x89, 0x50, 0x4E, 0x47])) + const upload = await run(['api', 'call', 'UploadNoteImage', '--path', 'noteId=abc', '--file', `image=@${imagePath}`], endpoint, configDir) + expect(upload.code, upload.stderr).to.equal(0) + expect(requests[0].contentType).to.include('multipart/form-data') + expect(requests[0].body).to.include('image/png') + expect(requests[0].body).to.include('filename="image.png"') + const uploadWithMime = await run(['api', 'call', 'UploadNoteImage', '--path', 'noteId=abc', '--file', `image=@${imagePath}`, '--mime', 'image/jpeg'], endpoint, configDir) + expect(uploadWithMime.code, uploadWithMime.stderr).to.equal(0) + expect(requests[1].body).to.include('image/jpeg') + contentType = 'application/x-ndjson' + responseBody = '{"id":1}\n{"id":2}\n' + const exportResult = await run(['api', 'call', 'ExportWebhookDeliveries', '--path', 'hookId=abc'], endpoint, configDir) + expect(exportResult.code, exportResult.stderr).to.equal(0) + expect(exportResult.stdout).to.equal(responseBody) + }) + + it('rejects unknown parameters before making a request and reports HTTP errors', async () => { + const invalid = await run(['api', 'call', 'GetNote', '--path', 'wrong=abc'], endpoint, configDir) + expect(invalid.code).not.to.equal(0) + expect(invalid.stderr).to.include('Missing required --path noteId') + expect(requests).to.have.length(0) + status = 404 + responseBody = JSON.stringify({error: 'Note not found'}) + const notFound = await run(['api', 'call', 'GetNote', '--path', 'noteId=abc'], endpoint, configDir) + expect(notFound.code).not.to.equal(0) + expect(notFound.stderr).to.include('HTTP 404') + expect(notFound.stderr).to.include('Note not found') + }) +}) diff --git a/tsconfig.json b/tsconfig.json index ab53f7f..3121cf6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,11 +2,12 @@ "compilerOptions": { "declaration": true, "importHelpers": true, - "module": "commonjs", + "module": "NodeNext", + "moduleResolution": "NodeNext", "outDir": "lib", "rootDir": "src", "strict": true, - "target": "es2017", + "target": "ES2022", "esModuleInterop": true, "skipLibCheck": true },