postgresai 0.15.0-dev.10 → 0.15.0-dev.11
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.
- package/README.md +79 -8
- package/bin/postgres-ai.ts +220 -141
- package/dist/bin/postgres-ai.js +4050 -694
- package/lib/checkup-dictionary.ts +0 -11
- package/lib/checkup.ts +14 -14
- package/lib/init.ts +1 -1
- package/lib/instances.ts +245 -0
- package/lib/mcp-server.ts +139 -18
- package/lib/metrics-loader.ts +3 -3
- package/lib/storage.ts +77 -1
- package/package.json +1 -1
- package/test/issues.cli.test.ts +395 -0
- package/test/mcp-server.test.ts +485 -2
- package/test/monitoring.test.ts +277 -0
- package/test/storage.test.ts +175 -1
package/bin/postgres-ai.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { startMcpServer } from "../lib/mcp-server";
|
|
|
14
14
|
import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment, fetchActionItem, fetchActionItems, createActionItem, updateActionItem, type ConfigChange } from "../lib/issues";
|
|
15
15
|
import { fetchReports, fetchAllReports, fetchReportFiles, fetchReportFileData, renderMarkdownForTerminal, parseFlexibleDate } from "../lib/reports";
|
|
16
16
|
import { resolveBaseUrls } from "../lib/util";
|
|
17
|
-
import { uploadFile, downloadFile, buildMarkdownLink } from "../lib/storage";
|
|
17
|
+
import { uploadFile, downloadFile, buildMarkdownLink, uploadAttachments, appendAttachmentsToContent } from "../lib/storage";
|
|
18
18
|
import { applyInitPlan, applyUninitPlan, buildInitPlan, buildUninitPlan, checkCurrentUserPermissions, connectWithSslFallback, DEFAULT_MONITORING_USER, formatPermissionCheckMessages, KNOWN_PROVIDERS, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, validateProvider, verifyInitSetup } from "../lib/init";
|
|
19
19
|
import { SupabaseClient, resolveSupabaseConfig, extractProjectRefFromUrl, applyInitPlanViaSupabase, verifyInitSetupViaSupabase, fetchPoolerDatabaseUrl, type PgCompatibleError } from "../lib/supabase";
|
|
20
20
|
import * as pkce from "../lib/pkce";
|
|
@@ -26,6 +26,17 @@ import { REPORT_GENERATORS, CHECK_INFO, generateAllReports } from "../lib/checku
|
|
|
26
26
|
import { getCheckupEntry } from "../lib/checkup-dictionary";
|
|
27
27
|
import { createCheckupReport, uploadCheckupReportJson, convertCheckupReportJsonToMarkdown, RpcError, formatRpcErrorForDisplay, withRetry } from "../lib/checkup-api";
|
|
28
28
|
import { generateCheckSummary } from "../lib/checkup-summary";
|
|
29
|
+
import {
|
|
30
|
+
type Instance,
|
|
31
|
+
InstancesParseError,
|
|
32
|
+
loadInstances,
|
|
33
|
+
buildInstance,
|
|
34
|
+
addInstanceToFile,
|
|
35
|
+
removeInstanceFromFile,
|
|
36
|
+
buildClientConfig,
|
|
37
|
+
sslOptionFromConnString,
|
|
38
|
+
warnIfLaxSslmode,
|
|
39
|
+
} from "../lib/instances";
|
|
29
40
|
|
|
30
41
|
// Node.js version check - require Node 18+
|
|
31
42
|
// Node 14 reached EOL in April 2023, Node 16 in September 2023.
|
|
@@ -419,19 +430,6 @@ interface ConfigResult {
|
|
|
419
430
|
apiKey: string;
|
|
420
431
|
}
|
|
421
432
|
|
|
422
|
-
/**
|
|
423
|
-
* Instance configuration
|
|
424
|
-
*/
|
|
425
|
-
interface Instance {
|
|
426
|
-
name: string;
|
|
427
|
-
conn_str?: string;
|
|
428
|
-
preset_metrics?: string;
|
|
429
|
-
custom_metrics?: any;
|
|
430
|
-
is_enabled?: boolean;
|
|
431
|
-
group?: string;
|
|
432
|
-
custom_tags?: Record<string, any>;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
433
|
/**
|
|
436
434
|
* Path resolution result
|
|
437
435
|
*/
|
|
@@ -2302,6 +2300,18 @@ const mon = program.command("mon").description("monitoring services management")
|
|
|
2302
2300
|
mon
|
|
2303
2301
|
.command("local-install")
|
|
2304
2302
|
.description("install local monitoring stack (generate config, start services)")
|
|
2303
|
+
.addHelpText(
|
|
2304
|
+
"after",
|
|
2305
|
+
[
|
|
2306
|
+
"",
|
|
2307
|
+
"Networking:",
|
|
2308
|
+
" Compose enables IPv6 on the project's default network so containers can",
|
|
2309
|
+
" reach IPv6-only databases (e.g. Supabase free-tier db.<ref>.supabase.co).",
|
|
2310
|
+
" Override on hosts whose Docker daemon cannot create an IPv6 network:",
|
|
2311
|
+
" PGAI_ENABLE_IPV6=false (accepted: true|false|yes|no, lowercase)",
|
|
2312
|
+
"",
|
|
2313
|
+
].join("\n"),
|
|
2314
|
+
)
|
|
2305
2315
|
.option("--demo", "demo mode with sample database", false)
|
|
2306
2316
|
.option("--api-key <key>", "Postgres AI API key for automated report uploads")
|
|
2307
2317
|
.option("--db-url <url>", "PostgreSQL connection URL to monitor")
|
|
@@ -2487,8 +2497,7 @@ mon
|
|
|
2487
2497
|
const db = m[5];
|
|
2488
2498
|
const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
|
|
2489
2499
|
|
|
2490
|
-
|
|
2491
|
-
fs.appendFileSync(instancesPath, body, "utf8");
|
|
2500
|
+
addInstanceToFile(instancesPath, buildInstance(instanceName, connStr));
|
|
2492
2501
|
console.log(`✓ Monitoring target '${instanceName}' added\n`);
|
|
2493
2502
|
|
|
2494
2503
|
// Test connection
|
|
@@ -2496,7 +2505,8 @@ mon
|
|
|
2496
2505
|
{
|
|
2497
2506
|
let testClient: InstanceType<typeof Client> | null = null;
|
|
2498
2507
|
try {
|
|
2499
|
-
|
|
2508
|
+
warnIfLaxSslmode(connStr);
|
|
2509
|
+
testClient = new Client(buildClientConfig(connStr, { connectionTimeoutMillis: 10000 }));
|
|
2500
2510
|
await testClient.connect();
|
|
2501
2511
|
const result = await testClient.query("select version();");
|
|
2502
2512
|
console.log("✓ Connection successful");
|
|
@@ -2535,8 +2545,7 @@ mon
|
|
|
2535
2545
|
const db = m[5];
|
|
2536
2546
|
const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
|
|
2537
2547
|
|
|
2538
|
-
|
|
2539
|
-
fs.appendFileSync(instancesPath, body, "utf8");
|
|
2548
|
+
addInstanceToFile(instancesPath, buildInstance(instanceName, connStr));
|
|
2540
2549
|
console.log(`✓ Monitoring target '${instanceName}' added\n`);
|
|
2541
2550
|
|
|
2542
2551
|
// Test connection
|
|
@@ -2544,7 +2553,8 @@ mon
|
|
|
2544
2553
|
{
|
|
2545
2554
|
let testClient: InstanceType<typeof Client> | null = null;
|
|
2546
2555
|
try {
|
|
2547
|
-
|
|
2556
|
+
warnIfLaxSslmode(connStr);
|
|
2557
|
+
testClient = new Client(buildClientConfig(connStr, { connectionTimeoutMillis: 10000 }));
|
|
2548
2558
|
await testClient.connect();
|
|
2549
2559
|
const result = await testClient.query("select version();");
|
|
2550
2560
|
console.log("✓ Connection successful");
|
|
@@ -3173,42 +3183,32 @@ targets
|
|
|
3173
3183
|
return;
|
|
3174
3184
|
}
|
|
3175
3185
|
|
|
3186
|
+
let instances: Instance[];
|
|
3176
3187
|
try {
|
|
3177
|
-
|
|
3178
|
-
const instances = yaml.load(content) as Instance[] | null;
|
|
3179
|
-
|
|
3180
|
-
if (!instances || !Array.isArray(instances) || instances.length === 0) {
|
|
3181
|
-
console.log("No monitoring targets configured");
|
|
3182
|
-
console.log("");
|
|
3183
|
-
console.log("To add a monitoring target:");
|
|
3184
|
-
console.log(" postgres-ai mon targets add <connection-string> <name>");
|
|
3185
|
-
console.log("");
|
|
3186
|
-
console.log("Example:");
|
|
3187
|
-
console.log(" postgres-ai mon targets add 'postgresql://user:pass@host:5432/db' my-db");
|
|
3188
|
-
return;
|
|
3189
|
-
}
|
|
3190
|
-
|
|
3191
|
-
// Filter out disabled instances (e.g., demo placeholders)
|
|
3192
|
-
const filtered = instances.filter((inst) => inst.name && inst.is_enabled !== false);
|
|
3193
|
-
|
|
3194
|
-
if (filtered.length === 0) {
|
|
3195
|
-
console.log("No monitoring targets configured");
|
|
3196
|
-
console.log("");
|
|
3197
|
-
console.log("To add a monitoring target:");
|
|
3198
|
-
console.log(" postgres-ai mon targets add <connection-string> <name>");
|
|
3199
|
-
console.log("");
|
|
3200
|
-
console.log("Example:");
|
|
3201
|
-
console.log(" postgres-ai mon targets add 'postgresql://user:pass@host:5432/db' my-db");
|
|
3202
|
-
return;
|
|
3203
|
-
}
|
|
3204
|
-
|
|
3205
|
-
for (const inst of filtered) {
|
|
3206
|
-
console.log(`Target: ${inst.name}`);
|
|
3207
|
-
}
|
|
3188
|
+
instances = loadInstances(instancesPath);
|
|
3208
3189
|
} catch (err) {
|
|
3209
3190
|
const message = err instanceof Error ? err.message : String(err);
|
|
3210
3191
|
console.error(`Error parsing instances.yml: ${message}`);
|
|
3211
3192
|
process.exitCode = 1;
|
|
3193
|
+
return;
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3196
|
+
// Filter out disabled instances (e.g., demo placeholders)
|
|
3197
|
+
const filtered = instances.filter((inst) => inst.name && inst.is_enabled !== false);
|
|
3198
|
+
|
|
3199
|
+
if (filtered.length === 0) {
|
|
3200
|
+
console.log("No monitoring targets configured");
|
|
3201
|
+
console.log("");
|
|
3202
|
+
console.log("To add a monitoring target:");
|
|
3203
|
+
console.log(" postgres-ai mon targets add <connection-string> <name>");
|
|
3204
|
+
console.log("");
|
|
3205
|
+
console.log("Example:");
|
|
3206
|
+
console.log(" postgres-ai mon targets add 'postgresql://user:pass@host:5432/db' my-db");
|
|
3207
|
+
return;
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
for (const inst of filtered) {
|
|
3211
|
+
console.log(`Target: ${inst.name}`);
|
|
3212
3212
|
}
|
|
3213
3213
|
});
|
|
3214
3214
|
targets
|
|
@@ -3231,40 +3231,17 @@ targets
|
|
|
3231
3231
|
const db = m[5];
|
|
3232
3232
|
const instanceName = name && name.trim() ? name.trim() : `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
|
|
3233
3233
|
|
|
3234
|
-
// Check if instance already exists
|
|
3235
3234
|
try {
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
const instances = yaml.load(content) as Instance[] | null || [];
|
|
3239
|
-
if (Array.isArray(instances)) {
|
|
3240
|
-
const exists = instances.some((inst) => inst.name === instanceName);
|
|
3241
|
-
if (exists) {
|
|
3242
|
-
console.error(`Monitoring target '${instanceName}' already exists`);
|
|
3243
|
-
process.exitCode = 1;
|
|
3244
|
-
return;
|
|
3245
|
-
}
|
|
3246
|
-
}
|
|
3247
|
-
}
|
|
3235
|
+
addInstanceToFile(file, buildInstance(instanceName, connStr));
|
|
3236
|
+
console.log(`Monitoring target '${instanceName}' added`);
|
|
3248
3237
|
} catch (err) {
|
|
3249
|
-
//
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
const
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
process.exitCode = 1;
|
|
3256
|
-
return;
|
|
3257
|
-
}
|
|
3258
|
-
}
|
|
3259
|
-
|
|
3260
|
-
// Add new instance — if instances.yml is a directory (Docker artifact), replace it with a file
|
|
3261
|
-
if (fs.existsSync(file) && fs.lstatSync(file).isDirectory()) {
|
|
3262
|
-
fs.rmSync(file, { recursive: true, force: true });
|
|
3238
|
+
// Surface InstancesParseError as-is so we don't silently overwrite a
|
|
3239
|
+
// corrupted file (which could discard several targets, including the
|
|
3240
|
+
// credentials in their conn_str values).
|
|
3241
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3242
|
+
console.error(message);
|
|
3243
|
+
process.exitCode = 1;
|
|
3263
3244
|
}
|
|
3264
|
-
const body = `- name: ${instanceName}\n conn_str: ${connStr}\n preset_metrics: full\n custom_metrics:\n is_enabled: true\n group: default\n custom_tags:\n env: production\n cluster: default\n node_name: ${instanceName}\n sink_type: ~sink_type~\n`;
|
|
3265
|
-
const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
3266
|
-
fs.appendFileSync(file, (content && !/\n$/.test(content) ? "\n" : "") + body, "utf8");
|
|
3267
|
-
console.log(`Monitoring target '${instanceName}' added`);
|
|
3268
3245
|
});
|
|
3269
3246
|
targets
|
|
3270
3247
|
.command("remove <name>")
|
|
@@ -3278,24 +3255,12 @@ targets
|
|
|
3278
3255
|
}
|
|
3279
3256
|
|
|
3280
3257
|
try {
|
|
3281
|
-
const
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
if (!instances || !Array.isArray(instances)) {
|
|
3285
|
-
console.error("Invalid instances.yml format");
|
|
3286
|
-
process.exitCode = 1;
|
|
3287
|
-
return;
|
|
3288
|
-
}
|
|
3289
|
-
|
|
3290
|
-
const filtered = instances.filter((inst) => inst.name !== name);
|
|
3291
|
-
|
|
3292
|
-
if (filtered.length === instances.length) {
|
|
3258
|
+
const removed = removeInstanceFromFile(file, name);
|
|
3259
|
+
if (!removed) {
|
|
3293
3260
|
console.error(`Monitoring target '${name}' not found`);
|
|
3294
3261
|
process.exitCode = 1;
|
|
3295
3262
|
return;
|
|
3296
3263
|
}
|
|
3297
|
-
|
|
3298
|
-
fs.writeFileSync(file, yaml.dump(filtered), "utf8");
|
|
3299
3264
|
console.log(`Monitoring target '${name}' removed`);
|
|
3300
3265
|
} catch (err) {
|
|
3301
3266
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -3314,35 +3279,35 @@ targets
|
|
|
3314
3279
|
return;
|
|
3315
3280
|
}
|
|
3316
3281
|
|
|
3282
|
+
let instances: Instance[];
|
|
3317
3283
|
try {
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
const instance = instances.find((inst) => inst.name === name);
|
|
3284
|
+
instances = loadInstances(instancesPath);
|
|
3285
|
+
} catch (err) {
|
|
3286
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3287
|
+
console.error(`Error parsing instances.yml: ${message}`);
|
|
3288
|
+
process.exitCode = 1;
|
|
3289
|
+
return;
|
|
3290
|
+
}
|
|
3291
|
+
const instance = instances.find((inst) => inst.name === name);
|
|
3328
3292
|
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3293
|
+
if (!instance) {
|
|
3294
|
+
console.error(`Monitoring target '${name}' not found`);
|
|
3295
|
+
process.exitCode = 1;
|
|
3296
|
+
return;
|
|
3297
|
+
}
|
|
3334
3298
|
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3299
|
+
if (!instance.conn_str) {
|
|
3300
|
+
console.error(`Connection string not found for monitoring target '${name}'`);
|
|
3301
|
+
process.exitCode = 1;
|
|
3302
|
+
return;
|
|
3303
|
+
}
|
|
3340
3304
|
|
|
3341
|
-
|
|
3305
|
+
console.log(`Testing connection to monitoring target '${name}'...`);
|
|
3342
3306
|
|
|
3343
|
-
|
|
3344
|
-
|
|
3307
|
+
warnIfLaxSslmode(instance.conn_str);
|
|
3308
|
+
const client = new Client(buildClientConfig(instance.conn_str, { connectionTimeoutMillis: 10000 }));
|
|
3345
3309
|
|
|
3310
|
+
try {
|
|
3346
3311
|
try {
|
|
3347
3312
|
await client.connect();
|
|
3348
3313
|
const result = await client.query('select version();');
|
|
@@ -3376,17 +3341,17 @@ auth
|
|
|
3376
3341
|
process.exitCode = 1;
|
|
3377
3342
|
return;
|
|
3378
3343
|
}
|
|
3379
|
-
|
|
3344
|
+
|
|
3380
3345
|
// Read existing config to check for defaultProject before updating
|
|
3381
3346
|
const existingConfig = config.readConfig();
|
|
3382
3347
|
const existingProject = existingConfig.defaultProject;
|
|
3383
|
-
|
|
3348
|
+
|
|
3384
3349
|
config.writeConfig({ apiKey: trimmedKey });
|
|
3385
3350
|
// When API key is set directly, only clear orgId (org selection may differ).
|
|
3386
3351
|
// Preserve defaultProject to avoid orphaning historical reports.
|
|
3387
3352
|
// If the new key lacks access to the project, upload will fail with a clear error.
|
|
3388
3353
|
config.deleteConfigKeys(["orgId"]);
|
|
3389
|
-
|
|
3354
|
+
|
|
3390
3355
|
console.log(`API key saved to ${config.getConfigPath()}`);
|
|
3391
3356
|
if (existingProject) {
|
|
3392
3357
|
console.log(`Note: Your default project "${existingProject}" has been preserved.`);
|
|
@@ -3570,13 +3535,13 @@ auth
|
|
|
3570
3535
|
const existingOrgId = existingConfig.orgId;
|
|
3571
3536
|
const existingProject = existingConfig.defaultProject;
|
|
3572
3537
|
const orgChanged = existingOrgId && existingOrgId !== orgId;
|
|
3573
|
-
|
|
3538
|
+
|
|
3574
3539
|
config.writeConfig({
|
|
3575
3540
|
apiKey: apiToken,
|
|
3576
3541
|
baseUrl: apiBaseUrl,
|
|
3577
3542
|
orgId: orgId,
|
|
3578
3543
|
});
|
|
3579
|
-
|
|
3544
|
+
|
|
3580
3545
|
// Only clear defaultProject if org actually changed
|
|
3581
3546
|
if (orgChanged && existingProject) {
|
|
3582
3547
|
config.deleteConfigKeys(["defaultProject"]);
|
|
@@ -3915,9 +3880,18 @@ issues
|
|
|
3915
3880
|
.command("post-comment <issueId> <content>")
|
|
3916
3881
|
.description("post a new comment to an issue")
|
|
3917
3882
|
.option("--parent <uuid>", "parent comment id")
|
|
3883
|
+
.option(
|
|
3884
|
+
"--attach <path>",
|
|
3885
|
+
"attach a file (uploads to storage and appends a markdown link; repeatable)",
|
|
3886
|
+
(value: string, previous: string[]) => {
|
|
3887
|
+
previous.push(value);
|
|
3888
|
+
return previous;
|
|
3889
|
+
},
|
|
3890
|
+
[] as string[]
|
|
3891
|
+
)
|
|
3918
3892
|
.option("--debug", "enable debug output")
|
|
3919
3893
|
.option("--json", "output raw JSON")
|
|
3920
|
-
.action(async (issueId: string, content: string, opts: { parent?: string; debug?: boolean; json?: boolean }) => {
|
|
3894
|
+
.action(async (issueId: string, content: string, opts: { parent?: string; attach?: string[]; debug?: boolean; json?: boolean }) => {
|
|
3921
3895
|
// Interpret escape sequences in content (e.g., \n -> newline)
|
|
3922
3896
|
if (opts.debug) {
|
|
3923
3897
|
// eslint-disable-next-line no-console
|
|
@@ -3929,7 +3903,11 @@ issues
|
|
|
3929
3903
|
console.error(`Debug: Interpreted content: ${JSON.stringify(content)}`);
|
|
3930
3904
|
}
|
|
3931
3905
|
|
|
3932
|
-
const
|
|
3906
|
+
const attachPaths = Array.isArray(opts.attach) ? opts.attach : [];
|
|
3907
|
+
const spinner = createTtySpinner(
|
|
3908
|
+
process.stdout.isTTY ?? false,
|
|
3909
|
+
attachPaths.length > 0 ? "Uploading attachments..." : "Posting comment..."
|
|
3910
|
+
);
|
|
3933
3911
|
try {
|
|
3934
3912
|
const rootOpts = program.opts<CliOptions>();
|
|
3935
3913
|
const cfg = config.readConfig();
|
|
@@ -3941,13 +3919,25 @@ issues
|
|
|
3941
3919
|
return;
|
|
3942
3920
|
}
|
|
3943
3921
|
|
|
3944
|
-
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
3922
|
+
const { apiBaseUrl, storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
3923
|
+
|
|
3924
|
+
let augmentedContent = content;
|
|
3925
|
+
if (attachPaths.length > 0) {
|
|
3926
|
+
const uploaded = await uploadAttachments({
|
|
3927
|
+
apiKey,
|
|
3928
|
+
storageBaseUrl,
|
|
3929
|
+
attachmentPaths: attachPaths,
|
|
3930
|
+
debug: !!opts.debug,
|
|
3931
|
+
});
|
|
3932
|
+
augmentedContent = appendAttachmentsToContent(content, uploaded);
|
|
3933
|
+
spinner.update("Posting comment...");
|
|
3934
|
+
}
|
|
3945
3935
|
|
|
3946
3936
|
const result = await createIssueComment({
|
|
3947
3937
|
apiKey,
|
|
3948
3938
|
apiBaseUrl,
|
|
3949
3939
|
issueId,
|
|
3950
|
-
content,
|
|
3940
|
+
content: augmentedContent,
|
|
3951
3941
|
parentCommentId: opts.parent,
|
|
3952
3942
|
debug: !!opts.debug,
|
|
3953
3943
|
});
|
|
@@ -3976,9 +3966,18 @@ issues
|
|
|
3976
3966
|
},
|
|
3977
3967
|
[] as string[]
|
|
3978
3968
|
)
|
|
3969
|
+
.option(
|
|
3970
|
+
"--attach <path>",
|
|
3971
|
+
"attach a file (uploads to storage and appends a markdown link to the description; repeatable)",
|
|
3972
|
+
(value: string, previous: string[]) => {
|
|
3973
|
+
previous.push(value);
|
|
3974
|
+
return previous;
|
|
3975
|
+
},
|
|
3976
|
+
[] as string[]
|
|
3977
|
+
)
|
|
3979
3978
|
.option("--debug", "enable debug output")
|
|
3980
3979
|
.option("--json", "output raw JSON")
|
|
3981
|
-
.action(async (rawTitle: string, opts: { orgId?: number; projectId?: number; description?: string; label?: string[]; debug?: boolean; json?: boolean }) => {
|
|
3980
|
+
.action(async (rawTitle: string, opts: { orgId?: number; projectId?: number; description?: string; label?: string[]; attach?: string[]; debug?: boolean; json?: boolean }) => {
|
|
3982
3981
|
const rootOpts = program.opts<CliOptions>();
|
|
3983
3982
|
const cfg = config.readConfig();
|
|
3984
3983
|
const { apiKey } = getConfig(rootOpts);
|
|
@@ -4005,16 +4004,33 @@ issues
|
|
|
4005
4004
|
const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
|
|
4006
4005
|
const labels = Array.isArray(opts.label) && opts.label.length > 0 ? opts.label.map(String) : undefined;
|
|
4007
4006
|
const projectId = typeof opts.projectId === "number" && !Number.isNaN(opts.projectId) ? opts.projectId : undefined;
|
|
4007
|
+
const attachPaths = Array.isArray(opts.attach) ? opts.attach : [];
|
|
4008
4008
|
|
|
4009
|
-
const spinner = createTtySpinner(
|
|
4009
|
+
const spinner = createTtySpinner(
|
|
4010
|
+
process.stdout.isTTY ?? false,
|
|
4011
|
+
attachPaths.length > 0 ? "Uploading attachments..." : "Creating issue..."
|
|
4012
|
+
);
|
|
4010
4013
|
try {
|
|
4011
|
-
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
4014
|
+
const { apiBaseUrl, storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
4015
|
+
|
|
4016
|
+
let augmentedDescription = description;
|
|
4017
|
+
if (attachPaths.length > 0) {
|
|
4018
|
+
const uploaded = await uploadAttachments({
|
|
4019
|
+
apiKey,
|
|
4020
|
+
storageBaseUrl,
|
|
4021
|
+
attachmentPaths: attachPaths,
|
|
4022
|
+
debug: !!opts.debug,
|
|
4023
|
+
});
|
|
4024
|
+
augmentedDescription = appendAttachmentsToContent(description ?? "", uploaded);
|
|
4025
|
+
spinner.update("Creating issue...");
|
|
4026
|
+
}
|
|
4027
|
+
|
|
4012
4028
|
const result = await createIssue({
|
|
4013
4029
|
apiKey,
|
|
4014
4030
|
apiBaseUrl,
|
|
4015
4031
|
title,
|
|
4016
4032
|
orgId,
|
|
4017
|
-
description,
|
|
4033
|
+
description: augmentedDescription,
|
|
4018
4034
|
projectId,
|
|
4019
4035
|
labels,
|
|
4020
4036
|
debug: !!opts.debug,
|
|
@@ -4045,9 +4061,18 @@ issues
|
|
|
4045
4061
|
[] as string[]
|
|
4046
4062
|
)
|
|
4047
4063
|
.option("--clear-labels", "set labels to an empty list")
|
|
4064
|
+
.option(
|
|
4065
|
+
"--attach <path>",
|
|
4066
|
+
"attach a file (uploads and appends a markdown link to --description; if --description is omitted the existing description is fetched and appended to; repeatable)",
|
|
4067
|
+
(value: string, previous: string[]) => {
|
|
4068
|
+
previous.push(value);
|
|
4069
|
+
return previous;
|
|
4070
|
+
},
|
|
4071
|
+
[] as string[]
|
|
4072
|
+
)
|
|
4048
4073
|
.option("--debug", "enable debug output")
|
|
4049
4074
|
.option("--json", "output raw JSON")
|
|
4050
|
-
.action(async (issueId: string, opts: { title?: string; description?: string; status?: string; label?: string[]; clearLabels?: boolean; debug?: boolean; json?: boolean }) => {
|
|
4075
|
+
.action(async (issueId: string, opts: { title?: string; description?: string; status?: string; label?: string[]; clearLabels?: boolean; attach?: string[]; debug?: boolean; json?: boolean }) => {
|
|
4051
4076
|
const rootOpts = program.opts<CliOptions>();
|
|
4052
4077
|
const cfg = config.readConfig();
|
|
4053
4078
|
const { apiKey } = getConfig(rootOpts);
|
|
@@ -4057,10 +4082,10 @@ issues
|
|
|
4057
4082
|
return;
|
|
4058
4083
|
}
|
|
4059
4084
|
|
|
4060
|
-
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
4085
|
+
const { apiBaseUrl, storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
4061
4086
|
|
|
4062
4087
|
const title = opts.title !== undefined ? interpretEscapes(String(opts.title)) : undefined;
|
|
4063
|
-
|
|
4088
|
+
let description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
|
|
4064
4089
|
|
|
4065
4090
|
let status: number | undefined = undefined;
|
|
4066
4091
|
if (opts.status !== undefined) {
|
|
@@ -4090,8 +4115,38 @@ issues
|
|
|
4090
4115
|
labels = opts.label.map(String);
|
|
4091
4116
|
}
|
|
4092
4117
|
|
|
4093
|
-
const
|
|
4118
|
+
const attachPaths = Array.isArray(opts.attach) ? opts.attach : [];
|
|
4119
|
+
const spinner = createTtySpinner(
|
|
4120
|
+
process.stdout.isTTY ?? false,
|
|
4121
|
+
attachPaths.length > 0 ? "Uploading attachments..." : "Updating issue..."
|
|
4122
|
+
);
|
|
4094
4123
|
try {
|
|
4124
|
+
if (attachPaths.length > 0) {
|
|
4125
|
+
// If the caller did not supply a new description, fetch the existing one
|
|
4126
|
+
// and append to it. This makes "add a screenshot to issue X" a one-step
|
|
4127
|
+
// operation rather than forcing the caller to copy-paste the existing
|
|
4128
|
+
// description first. Small race window if someone else updates
|
|
4129
|
+
// concurrently, which is acceptable for an interactive CLI / agent.
|
|
4130
|
+
if (description === undefined) {
|
|
4131
|
+
const existing = await fetchIssue({ apiKey, apiBaseUrl, issueId, debug: !!opts.debug });
|
|
4132
|
+
if (!existing) {
|
|
4133
|
+
spinner.stop();
|
|
4134
|
+
console.error(`Issue not found: ${issueId}`);
|
|
4135
|
+
process.exitCode = 1;
|
|
4136
|
+
return;
|
|
4137
|
+
}
|
|
4138
|
+
description = (existing as { description?: string | null }).description ?? "";
|
|
4139
|
+
}
|
|
4140
|
+
const uploaded = await uploadAttachments({
|
|
4141
|
+
apiKey,
|
|
4142
|
+
storageBaseUrl,
|
|
4143
|
+
attachmentPaths: attachPaths,
|
|
4144
|
+
debug: !!opts.debug,
|
|
4145
|
+
});
|
|
4146
|
+
description = appendAttachmentsToContent(description ?? "", uploaded);
|
|
4147
|
+
spinner.update("Updating issue...");
|
|
4148
|
+
}
|
|
4149
|
+
|
|
4095
4150
|
const result = await updateIssue({
|
|
4096
4151
|
apiKey,
|
|
4097
4152
|
apiBaseUrl,
|
|
@@ -4115,9 +4170,18 @@ issues
|
|
|
4115
4170
|
issues
|
|
4116
4171
|
.command("update-comment <commentId> <content>")
|
|
4117
4172
|
.description("update an existing issue comment")
|
|
4173
|
+
.option(
|
|
4174
|
+
"--attach <path>",
|
|
4175
|
+
"attach a file (uploads and appends a markdown link to <content>; repeatable)",
|
|
4176
|
+
(value: string, previous: string[]) => {
|
|
4177
|
+
previous.push(value);
|
|
4178
|
+
return previous;
|
|
4179
|
+
},
|
|
4180
|
+
[] as string[]
|
|
4181
|
+
)
|
|
4118
4182
|
.option("--debug", "enable debug output")
|
|
4119
4183
|
.option("--json", "output raw JSON")
|
|
4120
|
-
.action(async (commentId: string, content: string, opts: { debug?: boolean; json?: boolean }) => {
|
|
4184
|
+
.action(async (commentId: string, content: string, opts: { attach?: string[]; debug?: boolean; json?: boolean }) => {
|
|
4121
4185
|
if (opts.debug) {
|
|
4122
4186
|
// eslint-disable-next-line no-console
|
|
4123
4187
|
console.error(`Debug: Original content: ${JSON.stringify(content)}`);
|
|
@@ -4137,15 +4201,31 @@ issues
|
|
|
4137
4201
|
return;
|
|
4138
4202
|
}
|
|
4139
4203
|
|
|
4140
|
-
const
|
|
4204
|
+
const attachPaths = Array.isArray(opts.attach) ? opts.attach : [];
|
|
4205
|
+
const spinner = createTtySpinner(
|
|
4206
|
+
process.stdout.isTTY ?? false,
|
|
4207
|
+
attachPaths.length > 0 ? "Uploading attachments..." : "Updating comment..."
|
|
4208
|
+
);
|
|
4141
4209
|
try {
|
|
4142
|
-
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
4210
|
+
const { apiBaseUrl, storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
4211
|
+
|
|
4212
|
+
let augmentedContent = content;
|
|
4213
|
+
if (attachPaths.length > 0) {
|
|
4214
|
+
const uploaded = await uploadAttachments({
|
|
4215
|
+
apiKey,
|
|
4216
|
+
storageBaseUrl,
|
|
4217
|
+
attachmentPaths: attachPaths,
|
|
4218
|
+
debug: !!opts.debug,
|
|
4219
|
+
});
|
|
4220
|
+
augmentedContent = appendAttachmentsToContent(content, uploaded);
|
|
4221
|
+
spinner.update("Updating comment...");
|
|
4222
|
+
}
|
|
4143
4223
|
|
|
4144
4224
|
const result = await updateIssueComment({
|
|
4145
4225
|
apiKey,
|
|
4146
4226
|
apiBaseUrl,
|
|
4147
4227
|
commentId,
|
|
4148
|
-
content,
|
|
4228
|
+
content: augmentedContent,
|
|
4149
4229
|
debug: !!opts.debug,
|
|
4150
4230
|
});
|
|
4151
4231
|
spinner.stop();
|
|
@@ -4856,4 +4936,3 @@ mcp
|
|
|
4856
4936
|
program.parseAsync(process.argv).finally(() => {
|
|
4857
4937
|
closeReadline();
|
|
4858
4938
|
});
|
|
4859
|
-
|