@martin4455/redmine-mcp-ro 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,190 @@
1
+ import axios from 'axios';
2
+ import { open, unlink } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { Transform } from 'node:stream';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import { AttachmentParser } from './attachment-parser.js';
7
+ import { createRedmineHttpClient, positiveInteger, requestLimits } from './http-client.js';
8
+ import { safeErrorMessage } from './safe-error.js';
9
+ import { downloadDirectory } from './download-directory.js';
10
+ function attachmentFilename(value) {
11
+ // Check both path syntaxes even when running on Unix. Never silently strip directories.
12
+ if (typeof value !== 'string' || !value || value.startsWith('.') ||
13
+ /[\\/:\x00-\x1f\x7f]/.test(value) || /[. ]$/.test(value) ||
14
+ /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(value) ||
15
+ Buffer.byteLength(value) > 255) {
16
+ throw new Error('Unsafe attachment filename');
17
+ }
18
+ return value;
19
+ }
20
+ function responseFilename(response, fallback) {
21
+ const header = response.headers['content-disposition'];
22
+ if (typeof header === 'string') {
23
+ const extended = header.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
24
+ if (extended)
25
+ return attachmentFilename(decodeURIComponent(extended[1].trim()));
26
+ const match = header.match(/filename\s*=\s*(?:"([^"]*)"|([^;]*))/i);
27
+ if (match)
28
+ return attachmentFilename(match[1] ?? match[2].trim());
29
+ }
30
+ return attachmentFilename(fallback);
31
+ }
32
+ export class AttachmentDownloader {
33
+ config;
34
+ client;
35
+ parser = new AttachmentParser();
36
+ limits;
37
+ constructor(config) {
38
+ this.config = config;
39
+ this.client = createRedmineHttpClient(config);
40
+ this.limits = requestLimits(config);
41
+ }
42
+ async metadata(attachmentId) {
43
+ positiveInteger(attachmentId, 'attachmentId');
44
+ const response = await this.client.get(`/attachments/${attachmentId}.json`);
45
+ return response.data.attachment;
46
+ }
47
+ contentUrl(attachment) {
48
+ const url = attachment.content_url || attachment.href_url;
49
+ if (!url)
50
+ throw new Error('No content URL found for attachment');
51
+ // Root-relative content URLs belong to the server root, unlike API endpoint paths.
52
+ return new URL(url, `${this.config.url.replace(/\/+$/, '')}/`).href;
53
+ }
54
+ async downloadAttachment(attachmentId, outputDir, options = {}) {
55
+ positiveInteger(attachmentId, 'attachmentId');
56
+ if (options.timestamp !== undefined)
57
+ positiveInteger(options.timestamp, 'timestamp');
58
+ let attachment;
59
+ try {
60
+ await downloadDirectory(this.config.downloadDir, outputDir);
61
+ try {
62
+ attachment = await this.metadata(attachmentId);
63
+ }
64
+ catch (error) {
65
+ if (!axios.isAxiosError(error) || error.response?.status !== 404 || !(options.version || options.timestamp)) {
66
+ throw error;
67
+ }
68
+ attachment = { id: attachmentId, filename: options.fallbackFilename || `attachment_${attachmentId}` };
69
+ }
70
+ let url;
71
+ if (options.version || options.timestamp) {
72
+ const params = new URLSearchParams();
73
+ if (options.version)
74
+ params.set('version', 'true');
75
+ if (options.timestamp)
76
+ params.set('t', String(options.timestamp));
77
+ url = `/attachments/${attachmentId}?${params}`;
78
+ }
79
+ else {
80
+ url = this.contentUrl(attachment);
81
+ }
82
+ const response = await this.client.get(url, {
83
+ responseType: 'stream',
84
+ maxContentLength: this.limits.maxAttachmentBytes,
85
+ });
86
+ // A stream can fail while the filesystem is being prepared, before pipeline attaches.
87
+ response.data.once('error', () => { });
88
+ try {
89
+ const filename = responseFilename(response, attachment.filename);
90
+ const declaredSize = Number(response.headers['content-length']);
91
+ if (declaredSize > this.limits.maxAttachmentBytes)
92
+ throw new Error('Attachment exceeds the download size limit');
93
+ return await this.saveFile(response.data, filename, outputDir, attachment.content_type);
94
+ }
95
+ finally {
96
+ // Also stop a response rejected before a writer was opened (unsafe name, collision, etc.).
97
+ response.data.destroy();
98
+ }
99
+ }
100
+ catch (error) {
101
+ throw new Error(`Failed to download attachment ${attachmentId}: ${safeErrorMessage(error, [this.config.apiKey])}`);
102
+ }
103
+ }
104
+ async saveFile(stream, filename, outputDir, contentType) {
105
+ const directory = await downloadDirectory(this.config.downloadDir, outputDir, true);
106
+ const filepath = path.join(directory, filename);
107
+ if (path.dirname(filepath) !== directory)
108
+ throw new Error('Attachment path is outside the output directory');
109
+ // Exclusive creation rejects existing files and symlinks, including dangling ones.
110
+ const handle = await open(filepath, 'wx', 0o600);
111
+ let size = 0;
112
+ const maxBytes = this.limits.maxAttachmentBytes;
113
+ const limit = new Transform({
114
+ transform(chunk, _encoding, callback) {
115
+ size += chunk.byteLength;
116
+ callback(size > maxBytes ? new Error('Attachment exceeds the download size limit') : null, chunk);
117
+ },
118
+ });
119
+ try {
120
+ await pipeline(stream, limit, handle.createWriteStream());
121
+ return { filepath, filename, size, content_type: contentType };
122
+ }
123
+ catch (error) {
124
+ await handle.close().catch(() => { });
125
+ // Only a file exclusively created by this download is ever removed.
126
+ await unlink(filepath).catch(() => { });
127
+ throw error;
128
+ }
129
+ finally {
130
+ await handle.close().catch(() => { });
131
+ }
132
+ }
133
+ async downloadIssueAttachments(issueId, outputDir) {
134
+ return this.downloadAllIssueAttachments(issueId, outputDir, false);
135
+ }
136
+ async downloadAllIssueAttachments(issueId, outputDir, includeEmbedded = true) {
137
+ positiveInteger(issueId, 'issueId');
138
+ try {
139
+ await downloadDirectory(this.config.downloadDir, outputDir);
140
+ const response = await this.client.get(`/issues/${issueId}.json`, {
141
+ params: { include: includeEmbedded ? 'attachments,journals' : 'attachments' },
142
+ });
143
+ const issue = response.data.issue;
144
+ const allAttachments = this.parser.findAllAttachments(includeEmbedded ? issue : { attachments: issue.attachments }, this.config.url);
145
+ const results = [];
146
+ for (const attachment of allAttachments) {
147
+ try {
148
+ const result = await this.downloadAttachment(attachment.id, outputDir, {
149
+ version: attachment.version,
150
+ timestamp: attachment.timestamp,
151
+ fallbackFilename: attachment.filename || `attachment_${attachment.id}`,
152
+ });
153
+ results.push({
154
+ ...result,
155
+ attachment_id: attachment.id,
156
+ source: issue.attachments?.some(a => a.id === attachment.id) ? 'api' : 'embedded',
157
+ });
158
+ }
159
+ catch (error) {
160
+ console.error(`Failed to download attachment ${attachment.id}: ${safeErrorMessage(error, [this.config.apiKey])}`);
161
+ }
162
+ }
163
+ return results;
164
+ }
165
+ catch (error) {
166
+ throw new Error(`Failed to get issue attachments: ${safeErrorMessage(error, [this.config.apiKey])}`);
167
+ }
168
+ }
169
+ async getAttachmentContent(attachmentId) {
170
+ try {
171
+ const attachment = await this.metadata(attachmentId);
172
+ const response = await this.client.get(this.contentUrl(attachment), {
173
+ responseType: 'arraybuffer',
174
+ maxContentLength: this.limits.maxContentBytes,
175
+ });
176
+ const buffer = Buffer.from(response.data);
177
+ if (buffer.byteLength > this.limits.maxContentBytes)
178
+ throw new Error('Attachment exceeds the in-memory size limit');
179
+ return {
180
+ filename: attachment.filename,
181
+ content: buffer.toString('base64'),
182
+ content_type: attachment.content_type,
183
+ size: buffer.byteLength,
184
+ };
185
+ }
186
+ catch (error) {
187
+ throw new Error(`Failed to get attachment content: ${safeErrorMessage(error, [this.config.apiKey])}`);
188
+ }
189
+ }
190
+ }
@@ -0,0 +1,27 @@
1
+ import type { AttachmentIssue, ParsedAttachment } from './types.js';
2
+ export type { ParsedAttachment } from './types.js';
3
+ /**
4
+ * Parser to extract attachment references from HTML content
5
+ */
6
+ export declare class AttachmentParser {
7
+ /**
8
+ * Parse HTML content to find attachment links
9
+ * @param html - HTML content to parse
10
+ * @param baseUrl - Base URL of the Redmine instance
11
+ * @returns Array of parsed attachment information
12
+ */
13
+ parseHtml(html: string, baseUrl: string): ParsedAttachment[];
14
+ /**
15
+ * Parse issue data to find all embedded attachments
16
+ * @param issue - Issue data from Redmine API
17
+ * @param baseUrl - Base URL of the Redmine instance
18
+ * @returns Array of all attachments (API + embedded)
19
+ */
20
+ findAllAttachments(issue: AttachmentIssue, baseUrl: string): ParsedAttachment[];
21
+ /**
22
+ * Extract attachment IDs from HTML content
23
+ * @param html - HTML content to parse
24
+ * @returns Array of attachment IDs
25
+ */
26
+ extractAttachmentIds(html: string): number[];
27
+ }
@@ -0,0 +1,138 @@
1
+ import { JSDOM, VirtualConsole } from 'jsdom';
2
+ /**
3
+ * Parser to extract attachment references from HTML content
4
+ */
5
+ export class AttachmentParser {
6
+ /**
7
+ * Parse HTML content to find attachment links
8
+ * @param html - HTML content to parse
9
+ * @param baseUrl - Base URL of the Redmine instance
10
+ * @returns Array of parsed attachment information
11
+ */
12
+ parseHtml(html, baseUrl) {
13
+ if (!html)
14
+ return [];
15
+ let base;
16
+ try {
17
+ base = new URL(baseUrl);
18
+ }
19
+ catch {
20
+ return [];
21
+ }
22
+ if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password)
23
+ return [];
24
+ const attachments = [];
25
+ const ids = new Set();
26
+ // Scripts and resource loading stay disabled. Do not forward parser diagnostics:
27
+ // jsdom errors can include private document content, such as malformed CSS.
28
+ const dom = new JSDOM(html, { virtualConsole: new VirtualConsole() });
29
+ try {
30
+ const document = dom.window.document;
31
+ // Find all links that point to attachments
32
+ const links = document.querySelectorAll('a[href*="/attachments/"]');
33
+ links.forEach((link) => {
34
+ const href = link.getAttribute('href');
35
+ if (!href)
36
+ return;
37
+ // Parse attachment URL patterns:
38
+ // - /attachments/123
39
+ // - /attachments/123/filename.ext
40
+ // - /attachments/download/123/filename.ext
41
+ // - https://redmine.example.com/attachments/123?t=1700000000&version=true
42
+ // Inspect the resolved URL, not query/fragment text that resembles a path.
43
+ let url;
44
+ try {
45
+ url = new URL(href, base);
46
+ }
47
+ catch {
48
+ return;
49
+ }
50
+ if (url.origin !== base.origin || url.username || url.password ||
51
+ !['http:', 'https:'].includes(url.protocol))
52
+ return;
53
+ const attachmentMatch = url.pathname.match(/\/attachments\/(?:download\/)?(\d+)(?:\/|$)/);
54
+ if (!attachmentMatch)
55
+ return;
56
+ const attachmentId = Number(attachmentMatch[1]);
57
+ const filename = link.textContent?.trim() || '';
58
+ // Parse URL parameters
59
+ const timestamp = url.searchParams.get('t');
60
+ const parsedTimestamp = timestamp === null ? undefined : Number(timestamp);
61
+ const version = url.searchParams.get('version') === 'true';
62
+ // Avoid duplicates
63
+ if (Number.isSafeInteger(attachmentId) && attachmentId > 0 && !ids.has(attachmentId)) {
64
+ ids.add(attachmentId);
65
+ attachments.push({
66
+ id: attachmentId,
67
+ filename,
68
+ url: href,
69
+ timestamp: parsedTimestamp !== undefined && Number.isSafeInteger(parsedTimestamp) && parsedTimestamp > 0
70
+ ? parsedTimestamp : undefined,
71
+ version: version || undefined,
72
+ });
73
+ }
74
+ });
75
+ return attachments;
76
+ }
77
+ finally {
78
+ dom.window.close();
79
+ }
80
+ }
81
+ /**
82
+ * Parse issue data to find all embedded attachments
83
+ * @param issue - Issue data from Redmine API
84
+ * @param baseUrl - Base URL of the Redmine instance
85
+ * @returns Array of all attachments (API + embedded)
86
+ */
87
+ findAllAttachments(issue, baseUrl) {
88
+ const attachments = [];
89
+ const processedIds = new Set();
90
+ // First, add attachments from the API response
91
+ if (issue.attachments && Array.isArray(issue.attachments)) {
92
+ issue.attachments.forEach((attachment) => {
93
+ if (!processedIds.has(attachment.id)) {
94
+ attachments.push({
95
+ id: attachment.id,
96
+ filename: attachment.filename,
97
+ url: attachment.content_url || attachment.href_url || '',
98
+ });
99
+ processedIds.add(attachment.id);
100
+ }
101
+ });
102
+ }
103
+ // Parse description for embedded attachments
104
+ if (issue.description) {
105
+ const descAttachments = this.parseHtml(issue.description, baseUrl);
106
+ descAttachments.forEach(att => {
107
+ if (!processedIds.has(att.id)) {
108
+ attachments.push(att);
109
+ processedIds.add(att.id);
110
+ }
111
+ });
112
+ }
113
+ // Parse journals/comments for embedded attachments
114
+ if (issue.journals && Array.isArray(issue.journals)) {
115
+ issue.journals.forEach((journal) => {
116
+ if (journal.notes) {
117
+ const journalAttachments = this.parseHtml(journal.notes, baseUrl);
118
+ journalAttachments.forEach(att => {
119
+ if (!processedIds.has(att.id)) {
120
+ attachments.push(att);
121
+ processedIds.add(att.id);
122
+ }
123
+ });
124
+ }
125
+ });
126
+ }
127
+ return attachments;
128
+ }
129
+ /**
130
+ * Extract attachment IDs from HTML content
131
+ * @param html - HTML content to parse
132
+ * @returns Array of attachment IDs
133
+ */
134
+ extractAttachmentIds(html) {
135
+ const attachments = this.parseHtml(html, 'https://example.com');
136
+ return attachments.map(a => a.id);
137
+ }
138
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import inquirer from 'inquirer';
4
+ import chalk from 'chalk';
5
+ import { createRedmineHttpClient, redmineBaseUrl } from './http-client.js';
6
+ import { safeErrorMessage } from './safe-error.js';
7
+ import { ConfigurationError, validateProfileName } from './config-file.js';
8
+ import { LocalConfiguration, readConnection, validateApiKey } from './local-configuration.js';
9
+ import { CredentialStoreUnavailableError } from './credential-store.js';
10
+ import { ConfigLoader } from './config-loader.js';
11
+ const local = (options) => new LocalConfiguration({ directory: options.configDir });
12
+ function display(config) {
13
+ for (const [name, profile] of Object.entries(config?.profiles ?? {})) {
14
+ console.log(` ${name}${config?.defaultProfile === name ? ' (default)' : ''}`);
15
+ console.log(` Credential ID: ${profile.credentialId}`);
16
+ }
17
+ }
18
+ function report(change, message) {
19
+ console.log(chalk.green(message));
20
+ for (const { from, to } of change.profileRenames ?? []) {
21
+ // Legacy names may contain terminal control characters. Never print them literally.
22
+ const escaped = JSON.stringify(from).replace(/[\u0080-\uffff]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`);
23
+ console.log(`Renamed profile ${escaped} -> ${JSON.stringify(to)}. Update REDMINE_PROFILE or --profile if used.`);
24
+ }
25
+ for (const warning of change.warnings)
26
+ console.error(chalk.yellow(warning));
27
+ if (change.warnings.length)
28
+ process.exitCode = 1;
29
+ }
30
+ async function configure(options) {
31
+ const manager = local(options);
32
+ const current = manager.read();
33
+ console.log(`Configuring ${manager.configPath}`);
34
+ const { profileName } = await inquirer.prompt([
35
+ {
36
+ type: 'input', name: 'profileName', message: 'Profile name:', default: options.profile ?? 'default',
37
+ validate: (value) => { try {
38
+ validateProfileName(value);
39
+ return true;
40
+ }
41
+ catch {
42
+ return 'Use a short profile name containing letters, numbers, dots, underscores or hyphens.';
43
+ } },
44
+ },
45
+ ]);
46
+ let previous;
47
+ if (current?.profiles[profileName]) {
48
+ try {
49
+ previous = await readConnection(manager.store, current.profiles[profileName]);
50
+ }
51
+ catch (error) {
52
+ if (error instanceof CredentialStoreUnavailableError)
53
+ throw error;
54
+ console.log('The existing connection is missing or invalid. Enter complete connection details to replace it.');
55
+ }
56
+ }
57
+ const answers = await inquirer.prompt([
58
+ {
59
+ type: 'input', name: 'url', message: 'Redmine URL:',
60
+ default: previous?.url ?? 'https://redmine.example.com',
61
+ validate: (value) => { try {
62
+ redmineBaseUrl(value);
63
+ return true;
64
+ }
65
+ catch {
66
+ return 'Enter an HTTP(S) URL without credentials, a query or a fragment.';
67
+ } },
68
+ },
69
+ {
70
+ type: 'password', name: 'apiKey', message: 'API key (saved in the system credential store):', mask: '*',
71
+ validate: (value) => { try {
72
+ validateApiKey(value);
73
+ return true;
74
+ }
75
+ catch {
76
+ return 'Enter a nonempty API key without surrounding whitespace or line breaks.';
77
+ } },
78
+ },
79
+ { type: 'confirm', name: 'testConnection', message: 'Test connection?', default: true },
80
+ ]);
81
+ if (answers.testConnection) {
82
+ try {
83
+ const client = createRedmineHttpClient({ url: answers.url, apiKey: answers.apiKey, requestTimeoutMs: 5000 });
84
+ await client.get('/users/current.json');
85
+ console.log(chalk.green('Connection successful.'));
86
+ }
87
+ catch (error) {
88
+ console.error(chalk.yellow(safeErrorMessage(error, [answers.apiKey, answers.url])));
89
+ const answer = await inquirer.prompt([{ type: 'confirm', name: 'save', message: 'Save configuration anyway?', default: false }]);
90
+ if (!answer.save)
91
+ return;
92
+ }
93
+ }
94
+ report(await manager.configure(profileName, answers.url, answers.apiKey), `Profile reference saved to ${manager.configPath}. URL and API key stored together in the system credential store.`);
95
+ }
96
+ const program = new Command().name('redmine-mcp').description('Configure local Redmine profiles using the system credential store').version('0.3.0');
97
+ const command = (name, description) => program.command(name).description(description)
98
+ .option('-d, --config-dir <directory>', 'Directory containing .redmine-mcp (default: REDMINE_CONFIG_DIR or current directory)');
99
+ command('configure', 'Save the connection JSON in the system credential store')
100
+ .option('-p, --profile <name>', 'Profile name', 'default')
101
+ .action(configure);
102
+ command('migrate', 'Upgrade version 1.0/2.0 files to references with the complete connection in the system store')
103
+ .action(async (options) => {
104
+ const manager = local(options);
105
+ report(await manager.migrate(), `Configuration ready at ${manager.configPath}; no URLs or API keys in this file.`);
106
+ });
107
+ command('list', 'List profiles in the selected directory without accessing their tokens')
108
+ .action((options) => {
109
+ const manager = local(options);
110
+ console.log(`Configuration: ${manager.configPath}`);
111
+ const config = manager.read();
112
+ if (!config || !Object.keys(config.profiles).length)
113
+ console.log('No local profiles. Run redmine-mcp configure.');
114
+ else
115
+ display(config);
116
+ });
117
+ command('status', 'Check the selected local profile and its system credential')
118
+ .option('-p, --profile <name>', 'Profile to check')
119
+ .action(async (options) => {
120
+ const config = await new ConfigLoader({ directory: options.configDir, profile: options.profile }).getConfig();
121
+ if (!config)
122
+ throw new ConfigurationError('No local configuration. Run redmine-mcp configure --config-dir <directory>.');
123
+ console.log(`Configuration: ${config.source}\nProfile: ${config.profile}\nCredential: available in system store`);
124
+ });
125
+ command('use <profile>', 'Select the default profile in this local configuration')
126
+ .action(async (profile, options) => {
127
+ await local(options).useProfile(profile);
128
+ console.log(chalk.green(`Default profile: ${profile}`));
129
+ });
130
+ command('remove <profile>', 'Remove a local profile and its system credential')
131
+ .option('-y, --yes', 'Do not prompt for confirmation')
132
+ .action(async (profile, options) => {
133
+ validateProfileName(profile);
134
+ if (!options.yes) {
135
+ const answer = await inquirer.prompt([{ type: 'confirm', name: 'remove', message: `Remove profile ${profile} and its stored token?`, default: false }]);
136
+ if (!answer.remove)
137
+ return;
138
+ }
139
+ report(await local(options).remove(profile), `Removed profile: ${profile}`);
140
+ });
141
+ program.parseAsync().catch(error => {
142
+ console.error(chalk.red(error instanceof ConfigurationError ? error.message : 'Configuration failed. Check the selected directory and system credential store.'));
143
+ process.exitCode = 1;
144
+ });
@@ -0,0 +1,18 @@
1
+ import type { ConfigFile, StoredConfigFile } from './types.js';
2
+ export declare const CONFIG_FILENAME = ".redmine-mcp";
3
+ export declare const CREDENTIAL_ID: RegExp;
4
+ export declare class ConfigurationError extends Error {
5
+ }
6
+ export declare function normalizeRedmineUrl(value: string): string;
7
+ export declare function validateProfileName(value: string): void;
8
+ export declare function parseConfig(raw: string): StoredConfigFile;
9
+ export interface ConfigSnapshot {
10
+ raw: string | null;
11
+ config: StoredConfigFile | null;
12
+ }
13
+ export declare function readConfig(configPath: string): ConfigSnapshot;
14
+ export declare function requireModern(config: StoredConfigFile | null): ConfigFile;
15
+ /** Atomic replacement; temporary files contain validated, secret-free metadata only. */
16
+ export declare function writeConfig(configPath: string, config: ConfigFile, expectedRaw: string | null): void;
17
+ export declare function withConfigLock<T>(configPath: string, action: () => Promise<T>): Promise<T>;
18
+ export declare function localConfigPath(directory?: string): string;