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 55ff35e..1d712f3 100644 Binary files a/hackmd-cli.skill and b/hackmd-cli.skill differ diff --git a/hackmd-cli/SKILL.md b/hackmd-cli/SKILL.md index 32b75df..c444e06 100644 --- a/hackmd-cli/SKILL.md +++ b/hackmd-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: hackmd-cli -description: HackMD command-line interface for managing personal/team notes and folders. Use this skill when users want to create, read, update, delete, reorder, or export HackMD notes and folders via CLI, manage team content, list teams, view browsing history, or automate HackMD workflows. +description: HackMD command-line interface for managing notes and folders or calling any v1 API operation through the installed API client. Use for HackMD content, team workflows, exports, or API automation. --- # HackMD CLI @@ -35,6 +35,20 @@ export HMD_API_ENDPOINT_URL=https://your.hackmd-ee.endpoint ## Commands +### Full v1 API via the installed API client + +Prefer the existing `notes`, `folders`, and other focused commands when available. For any other v1 operation, discover the exact operation ID and parameters offline, then call it through the API client's raw layer: + +```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 CreateNote --body @note.json +hackmd-cli api call UploadNoteImage --path noteId=abc --file image=@photo.png +``` + +Repeat `--path key=value`, `--query key=value`, or `--header 'Name: value'` as needed. `--body` accepts JSON text, `@file`, or `-` for stdin. `--file image=@path` is for multipart upload; use `--mime` if the extension is unknown. `--include` shows HTTP status and headers. The operation list describes the installed API client, not necessarily an older EE server; check server support before using a new operation. Generic writes are not retried automatically. Do not run writes or deletes without the user's authorization. + ### Authentication ```bash diff --git a/src/api/operations.ts b/src/api/operations.ts new file mode 100644 index 0000000..b095c4b --- /dev/null +++ b/src/api/operations.ts @@ -0,0 +1,68 @@ +import type {OperationId} from '@hackmd/api/raw' + +import {operationRegistry} from '@hackmd/api/raw' + +export type Operation = { + call: unknown; + method: string; + parameters: ReadonlyArray<{in: string; name: string; required: boolean; type: string}>; + 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 },