gangtise-openapi-cli 0.50.0 → 0.52.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.
- package/README.md +94 -49
- package/dist/src/cli.js +29 -8
- package/dist/src/commands/ai.js +27 -12
- package/dist/src/commands/alternative.js +2 -2
- package/dist/src/commands/bond.js +4 -4
- package/dist/src/commands/fund.js +1 -1
- package/dist/src/commands/fundamental.js +11 -11
- package/dist/src/commands/indicator.js +5 -5
- package/dist/src/commands/insight.js +38 -17
- package/dist/src/commands/quote.js +6 -6
- package/dist/src/commands/raw.js +3 -3
- package/dist/src/commands/reference.js +35 -3
- package/dist/src/commands/shared.js +20 -8
- package/dist/src/commands/tool.js +9 -4
- package/dist/src/commands/update.js +11 -0
- package/dist/src/commands/vault.js +17 -16
- package/dist/src/core/client.js +21 -4
- package/dist/src/core/download.js +12 -3
- package/dist/src/core/driveUpload.js +6 -2
- package/dist/src/core/dryRun.js +68 -0
- package/dist/src/core/endpoints.js +87 -6
- package/dist/src/core/errors.js +2 -2
- package/dist/src/core/etfComponents.js +65 -8
- package/dist/src/core/exitStatus.js +4 -0
- package/dist/src/core/fileParse.js +10 -3
- package/dist/src/core/printer.js +39 -7
- package/dist/src/core/statePaths.js +41 -0
- package/dist/src/core/transport.js +7 -0
- package/gangtise-openapi/SKILL.md +43 -156
- package/gangtise-openapi/references/billing.md +142 -0
- package/gangtise-openapi/references/commands/ai.md +26 -0
- package/gangtise-openapi/references/commands/fund.md +3 -3
- package/gangtise-openapi/references/commands/fundamental.md +2 -1
- package/gangtise-openapi/references/commands/indicator.md +106 -15
- package/gangtise-openapi/references/commands/insight.md +31 -6
- package/gangtise-openapi/references/commands/reference-and-lookup.md +42 -4
- package/gangtise-openapi/references/commands/tool.md +2 -2
- package/gangtise-openapi/references/commands/vault.md +26 -4
- package/gangtise-openapi/references/errors.md +3 -3
- package/gangtise-openapi/references/examples.md +3 -3
- package/gangtise-openapi/references/lookup-ids.md +13 -0
- package/gangtise-openapi/references/response-schema.md +8 -2
- package/package.json +2 -2
|
@@ -2,15 +2,18 @@ import { Command, Option } from "commander";
|
|
|
2
2
|
import { POLL_MAX_ATTEMPTS } from "../core/asyncContent.js";
|
|
3
3
|
import { collectList, maybeArray, parseNumberOption, parseOptionalNumberOption } from "../core/args.js";
|
|
4
4
|
import { ValidationError } from "../core/errors.js";
|
|
5
|
+
import { assertOutputFilePath } from "../core/download.js";
|
|
5
6
|
import { fetchFileParseResult, pollFileParseResult, submitFileParse } from "../core/fileParse.js";
|
|
6
7
|
import { markFailed } from "../core/exitStatus.js";
|
|
7
|
-
import { emit, withClient } from "./shared.js";
|
|
8
|
+
import { emit, withClient, OUTPUT_HELP } from "./shared.js";
|
|
8
9
|
export const tool = new Command("tool").description("Research tool APIs: PDF parsing");
|
|
9
10
|
tool.command("file-parse").description("Parse a PDF into Markdown + images (async; 0.8 credits/page, billed at submit)")
|
|
10
11
|
.requiredOption("--file <path>", "PDF to upload (max 100MB / 500 pages)")
|
|
11
12
|
.option("--wait", "Wait for the parse to finish and save the result ZIP (blocking, up to ~5 min)")
|
|
12
|
-
.option("--output <path>", "Where to save the result ZIP (used with --wait)")
|
|
13
|
+
.option("--output <path>", "Where to save the result ZIP (a file path, not a directory; used with --wait)")
|
|
13
14
|
.action((options) => withClient(async (client) => {
|
|
15
|
+
if (options.wait && options.output)
|
|
16
|
+
await assertOutputFilePath(options.output);
|
|
14
17
|
const taskId = await submitFileParse(client, options.file);
|
|
15
18
|
if (!options.wait) {
|
|
16
19
|
process.stderr.write(`File parse task submitted. taskId: ${taskId}\n`);
|
|
@@ -25,8 +28,10 @@ tool.command("file-parse").description("Parse a PDF into Markdown + images (asyn
|
|
|
25
28
|
}));
|
|
26
29
|
tool.command("file-parse-check").description("Download a finished file-parse result ZIP by taskId (free)")
|
|
27
30
|
.requiredOption("--task-id <id>", "taskId from 'tool file-parse'")
|
|
28
|
-
.option("--output <path>", "Where to save the result ZIP")
|
|
31
|
+
.option("--output <path>", "Where to save the result ZIP (a file path, not a directory)")
|
|
29
32
|
.action((options) => withClient(async (client) => {
|
|
33
|
+
if (options.output)
|
|
34
|
+
await assertOutputFilePath(options.output);
|
|
30
35
|
if (await fetchFileParseResult(client, options.taskId, options.output) === "pending") {
|
|
31
36
|
process.stdout.write(`${JSON.stringify({ taskId: options.taskId, status: "pending", hint: "Parse not finished yet, retry in ~1 minute" })}\n`);
|
|
32
37
|
}
|
|
@@ -39,7 +44,7 @@ tool.command("web-search").description("Search the public web for research: dedu
|
|
|
39
44
|
.option("--site <domain>", "Restrict to a registered domain or subdomain, e.g. csrc.gov.cn (repeatable, max 10, OR-ed)", collectList, [])
|
|
40
45
|
.option("--include-content", "Return page body as Markdown; caps --size at 5")
|
|
41
46
|
.option("--max-content-chars <number>", "Max chars per body, 1000-20000 (only with --include-content)")
|
|
42
|
-
.option("--format <format>", "Output format", "table").option("--output <path>")
|
|
47
|
+
.option("--format <format>", "Output format", "table").option("--output <path>", OUTPUT_HELP)
|
|
43
48
|
.action((options) => emit(options, (client) => {
|
|
44
49
|
const size = parseNumberOption(options.size ?? (options.includeContent ? "5" : "10"), "--size", { integer: true, min: 1, max: 20 });
|
|
45
50
|
if (options.includeContent && size > 5)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import { isVersionNewer } from "../core/args.js";
|
|
4
|
+
import { isDryRun } from "../core/dryRun.js";
|
|
4
5
|
import { installVersion, refreshSkillCopies } from "../core/selfUpdate.js";
|
|
5
6
|
import { latestVersion, PACKAGE_NAME, REGISTRY } from "../core/updateCheck.js";
|
|
6
7
|
import { CLI_VERSION, PACKAGE_ROOT } from "../version.js";
|
|
@@ -9,6 +10,16 @@ export const update = new Command("update")
|
|
|
9
10
|
.option("--check", "Only report whether a newer version is published; install nothing")
|
|
10
11
|
.option("--no-skill", "Leave the Agent Skill copies as they are")
|
|
11
12
|
.action(async (options) => {
|
|
13
|
+
if (isDryRun()) {
|
|
14
|
+
const steps = [`look up the latest published version on ${REGISTRY}`];
|
|
15
|
+
if (!options.check) {
|
|
16
|
+
steps.push(`if newer than ${CLI_VERSION}: npm install -g ${PACKAGE_NAME}@<latest> --registry=${REGISTRY}`);
|
|
17
|
+
if (options.skill)
|
|
18
|
+
steps.push("refresh older copies of the Agent Skill");
|
|
19
|
+
}
|
|
20
|
+
process.stdout.write(`${JSON.stringify({ dryRun: true, command: "update", steps, note: "Nothing was looked up or installed." }, null, 2)}\n`);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
12
23
|
const latest = await latestVersion(undefined, 15_000, Date.now(), true);
|
|
13
24
|
if (!latest)
|
|
14
25
|
throw new Error(`Could not reach ${REGISTRY} to look up the latest version. Check the network and retry, or install directly: npm install -g ${PACKAGE_NAME}@latest --registry=${REGISTRY}`);
|
|
@@ -3,13 +3,13 @@ import { collectList, collectNames, parseFrom, parseSize } from "../core/args.js
|
|
|
3
3
|
import { uploadDriveFile } from "../core/driveUpload.js";
|
|
4
4
|
import { ValidationError } from "../core/errors.js";
|
|
5
5
|
import { flagFailedItems } from "../core/normalize.js";
|
|
6
|
-
import { emit, addDownloadCommand, assertConfirmed, field, value, required, requiredList, list, numberList, format, output, from, size, startTime, endTime, query, RESEARCH_AREA_CITIC_OR_DIRECTION } from "./shared.js";
|
|
6
|
+
import { emit, addDownloadCommand, assertConfirmed, field, value, required, requiredList, list, numberList, format, output, from, size, startTime, endTime, query, RESEARCH_AREA_CITIC_OR_DIRECTION, OUTPUT_HELP } from "./shared.js";
|
|
7
7
|
export const vault = new Command("vault").description("Vault APIs");
|
|
8
8
|
query(vault, "drive-list", {
|
|
9
9
|
endpoint: "vault.drive.list",
|
|
10
10
|
cache: { endpointKey: "vault.drive.list", idField: "fileId" },
|
|
11
11
|
fields: [
|
|
12
|
-
from(), size(), startTime(), endTime(), value("--keyword <text>",
|
|
12
|
+
from(), size(), startTime(), endTime(), value("--keyword <text>", "Keyword", "keyword"),
|
|
13
13
|
numberList("--file-type <number>", "File type", "fileTypeList"),
|
|
14
14
|
numberList("--space-type <number>", "Space type", "spaceTypeList"),
|
|
15
15
|
format(), output(),
|
|
@@ -32,7 +32,7 @@ vault.command("drive-upload").description("Upload a file to the AI drive (free;
|
|
|
32
32
|
.addOption(driveSpaceOption("Target space: 1=my drive 2=tenant drive (shared with your whole tenant)"))
|
|
33
33
|
.option("--folder-id <id>", "Target folder ID in that space; omit (or 'root') for the space's root")
|
|
34
34
|
.option("--title <name>", "Name in the drive, max 200 characters; defaults to the local file name")
|
|
35
|
-
.option("--format <format>", "Output format", "json").option("--output <path>")
|
|
35
|
+
.option("--format <format>", "Output format", "json").option("--output <path>", OUTPUT_HELP)
|
|
36
36
|
.action((options) => emit(options, (client) => uploadDriveFile(client, options.file, { spaceType: Number(options.spaceType), folderId: options.folderId, title: options.title })));
|
|
37
37
|
query(vault, "drive-create-folder", {
|
|
38
38
|
description: "Create a drive folder (free). Same-name folders are allowed, so re-running creates a second one",
|
|
@@ -57,7 +57,7 @@ query(vault, "drive-rename", {
|
|
|
57
57
|
vault.command("drive-move-file").description("Move drive files into a folder of the SAME space (free)")
|
|
58
58
|
.requiredOption("--file-id <id>", "File ID (repeat or comma-separate)", collectList)
|
|
59
59
|
.requiredOption("--target-folder-id <id>", "Destination folder ID, or 'root'. Files of the other space land in failList (空间不一致)")
|
|
60
|
-
.option("--format <format>", "Output format", "json").option("--output <path>")
|
|
60
|
+
.option("--format <format>", "Output format", "json").option("--output <path>", OUTPUT_HELP)
|
|
61
61
|
.action((options) => emit(options, async (client) => {
|
|
62
62
|
const data = await client.call("vault.drive.move-file", { fileIdList: options.fileId, targetFolderId: options.targetFolderId });
|
|
63
63
|
flagFailedItems(data, "vault drive-move-file");
|
|
@@ -77,7 +77,7 @@ vault.command("drive-copy").description("Copy files, or one folder with everythi
|
|
|
77
77
|
.option("--target-folder-id <id>", "Destination folder ID in the other space, or 'root' for that space's root")
|
|
78
78
|
.option("--folder-id <id>", "Source folder ID; use with --target-parent-id")
|
|
79
79
|
.option("--target-parent-id <id>", "Destination parent folder ID in the other space, or 'root' for that space's root")
|
|
80
|
-
.option("--format <format>", "Output format", "json").option("--output <path>")
|
|
80
|
+
.option("--format <format>", "Output format", "json").option("--output <path>", OUTPUT_HELP)
|
|
81
81
|
.action((options) => {
|
|
82
82
|
const byFile = options.fileId.length > 0 || options.targetFolderId !== undefined;
|
|
83
83
|
const byFolder = options.folderId !== undefined || options.targetParentId !== undefined;
|
|
@@ -101,7 +101,7 @@ vault.command("drive-copy").description("Copy files, or one folder with everythi
|
|
|
101
101
|
vault.command("drive-delete-file").description("Delete drive files — irreversible (free)")
|
|
102
102
|
.requiredOption("--file-id <id>", "File ID to delete (repeat or comma-separate)", collectList)
|
|
103
103
|
.option("--yes", "Required: confirm the deletion")
|
|
104
|
-
.option("--format <format>", "Output format", "json").option("--output <path>")
|
|
104
|
+
.option("--format <format>", "Output format", "json").option("--output <path>", OUTPUT_HELP)
|
|
105
105
|
.action(async (options) => {
|
|
106
106
|
assertConfirmed("vault.drive.delete-file", Boolean(options.yes), options.fileId.join("、"));
|
|
107
107
|
await emit(options, async (client) => {
|
|
@@ -113,7 +113,7 @@ vault.command("drive-delete-file").description("Delete drive files — irreversi
|
|
|
113
113
|
vault.command("drive-delete-folder").description("Delete a drive folder AND every subfolder and file inside it — irreversible (free)")
|
|
114
114
|
.requiredOption("--folder-id <id>", "Folder ID to delete")
|
|
115
115
|
.option("--yes", "Required: confirm the deletion")
|
|
116
|
-
.option("--format <format>", "Output format", "json").option("--output <path>")
|
|
116
|
+
.option("--format <format>", "Output format", "json").option("--output <path>", OUTPUT_HELP)
|
|
117
117
|
.action(async (options) => {
|
|
118
118
|
assertConfirmed("vault.drive.delete-folder", Boolean(options.yes), options.folderId);
|
|
119
119
|
await emit(options, (client) => client.call("vault.drive.delete-folder", { folderId: options.folderId }));
|
|
@@ -122,7 +122,7 @@ query(vault, "record-list", {
|
|
|
122
122
|
endpoint: "vault.record.list",
|
|
123
123
|
cache: { endpointKey: "vault.record.list", idField: "recordId" },
|
|
124
124
|
fields: [
|
|
125
|
-
from(), size(), startTime(), endTime(), value("--keyword <text>",
|
|
125
|
+
from(), size(), startTime(), endTime(), value("--keyword <text>", "Keyword", "keyword"),
|
|
126
126
|
list("--category <name>", "Recording type: upload/link/mobile/gtNote/pc/share", "categoryList"),
|
|
127
127
|
numberList("--space-type <number>", "Space type: 1=my records / 2=tenant records", "spaceTypeList"),
|
|
128
128
|
format(), output(),
|
|
@@ -133,7 +133,7 @@ query(vault, "my-conference-list", {
|
|
|
133
133
|
endpoint: "vault.my-conference.list",
|
|
134
134
|
cache: { endpointKey: "vault.my-conference.list", idField: "conferenceId" },
|
|
135
135
|
fields: [
|
|
136
|
-
from(), size(), startTime(), endTime(), value("--keyword <text>",
|
|
136
|
+
from(), size(), startTime(), endTime(), value("--keyword <text>", "Keyword", "keyword"),
|
|
137
137
|
list("--research-area <id>", RESEARCH_AREA_CITIC_OR_DIRECTION, "researchAreaList"),
|
|
138
138
|
list("--security <code>", "Security code", "securityList"),
|
|
139
139
|
list("--institution <id>", "Institution ID", "institutionList"),
|
|
@@ -146,15 +146,16 @@ addDownloadCommand(vault, { endpointKey: "vault.my-conference.download", name: "
|
|
|
146
146
|
query(vault, "wechat-message-list", {
|
|
147
147
|
endpoint: "vault.wechat-message.list",
|
|
148
148
|
fields: [
|
|
149
|
-
from(), size(), startTime(), endTime(), value("--keyword <text>",
|
|
149
|
+
from(), size(), startTime(), endTime(), value("--keyword <text>", "Keyword", "keyword"),
|
|
150
150
|
list("--security <code>", "Security code (e.g. 000001.SZ)", "securityList"),
|
|
151
151
|
list("--wechat-group-id <id>", "WeChat group ID", "wechatGroupIdList"),
|
|
152
152
|
list("--industry <id>", "Industry ID -- citicIndustry codes (1008001xx) only; swIndustry codes and unknown values are rejected with 100005", "industryIdList"),
|
|
153
|
-
list("--category <name>", "Message type: text/image/
|
|
153
|
+
list("--category <name>", "Message type: text/image/document/url/audio/video; any other value is rejected with 100005", "categoryList"),
|
|
154
154
|
list("--tag <name>", "Tag: roadShow/research/strategyMeeting/meetingSummary/industryComment/companyComment/earningsReview", "tagList"),
|
|
155
155
|
format(), output(),
|
|
156
156
|
],
|
|
157
157
|
});
|
|
158
|
+
addDownloadCommand(vault, { endpointKey: "vault.wechat-message.download", name: "wechat-message-download", idOption: "--msg-id", idField: "msgId", fallbackPrefix: "wechat-file" });
|
|
158
159
|
query(vault, "wechat-chatroom-list", {
|
|
159
160
|
endpoint: "vault.wechat-chatroom.list",
|
|
160
161
|
fields: [
|
|
@@ -192,7 +193,7 @@ vault.command("stock-pool-add-stock")
|
|
|
192
193
|
.requiredOption("--pool-id <id>", "Target pool ID from 'stock-pool-list'")
|
|
193
194
|
.requiredOption("--security <code>", "Security code, e.g. 600519.SH (repeat or comma-separate)", collectList)
|
|
194
195
|
.option("--format <format>", "Output format", "table")
|
|
195
|
-
.option("--output <path>")
|
|
196
|
+
.option("--output <path>", OUTPUT_HELP)
|
|
196
197
|
.action((options) => emit(options, async (client) => {
|
|
197
198
|
const data = await client.call("vault.stock-pool.add-stock", { poolId: options.poolId, securityCodeList: options.security });
|
|
198
199
|
flagFailedItems(data, "vault stock-pool-add-stock");
|
|
@@ -202,7 +203,7 @@ vault.command("stock-pool-remove-stock")
|
|
|
202
203
|
.requiredOption("--pool-id <id>", "Target pool ID from 'stock-pool-list'")
|
|
203
204
|
.requiredOption("--security <code>", "Security code, e.g. 600519.SH (repeat or comma-separate)", collectList)
|
|
204
205
|
.option("--format <format>", "Output format", "table")
|
|
205
|
-
.option("--output <path>")
|
|
206
|
+
.option("--output <path>", OUTPUT_HELP)
|
|
206
207
|
.action((options) => emit(options, async (client) => {
|
|
207
208
|
const data = await client.call("vault.stock-pool.remove-stock", { poolId: options.poolId, securityCodeList: options.security });
|
|
208
209
|
flagFailedItems(data, "vault stock-pool-remove-stock");
|
|
@@ -212,7 +213,7 @@ vault.command("stock-pool-delete")
|
|
|
212
213
|
.requiredOption("--pool-id <id>", "Pool ID to delete; repeat or comma-separate for multiple", collectList)
|
|
213
214
|
.option("--yes", "Required: confirm the deletion")
|
|
214
215
|
.option("--format <format>", "Output format", "table")
|
|
215
|
-
.option("--output <path>")
|
|
216
|
+
.option("--output <path>", OUTPUT_HELP)
|
|
216
217
|
.action(async (options) => {
|
|
217
218
|
assertConfirmed("vault.stock-pool.delete", Boolean(options.yes), options.poolId.join("、"));
|
|
218
219
|
await emit(options, async (client) => {
|
|
@@ -226,7 +227,7 @@ vault.command("stock-note-list").description("List your stock notes: all, by sec
|
|
|
226
227
|
.option("--note-id <id>", "Note ID (repeat or comma-separate); not together with --security", collectList)
|
|
227
228
|
.option("--from <number>", "Starting offset", "0")
|
|
228
229
|
.option("--size <number>", "Total rows to return; omit to fetch all")
|
|
229
|
-
.option("--format <format>", "Output format", "json").option("--output <path>")
|
|
230
|
+
.option("--format <format>", "Output format", "json").option("--output <path>", OUTPUT_HELP)
|
|
230
231
|
.action((options) => {
|
|
231
232
|
if (options.security?.length && options.noteId?.length)
|
|
232
233
|
throw new ValidationError("pass --security or --note-id, not both");
|
|
@@ -255,7 +256,7 @@ query(vault, "stock-note-update", {
|
|
|
255
256
|
vault.command("stock-note-delete").description("Delete stock notes — irreversible (free)")
|
|
256
257
|
.requiredOption("--note-id <id>", "Note ID to delete; repeat or comma-separate, max 100", collectList)
|
|
257
258
|
.option("--yes", "Required: confirm the deletion")
|
|
258
|
-
.option("--format <format>", "Output format", "json").option("--output <path>")
|
|
259
|
+
.option("--format <format>", "Output format", "json").option("--output <path>", OUTPUT_HELP)
|
|
259
260
|
.action(async (options) => {
|
|
260
261
|
assertConfirmed("vault.stock-note.delete", Boolean(options.yes), options.noteId.join("、"));
|
|
261
262
|
await emit(options, async (client) => {
|
package/dist/src/core/client.js
CHANGED
|
@@ -9,13 +9,16 @@ import { ApiError, attachEnvelopeTraceId, markStructural, ValidationError } from
|
|
|
9
9
|
import { COSTLY_FETCH_CREDITS, ENDPOINTS, resolveTimeoutMs } from "./endpoints.js";
|
|
10
10
|
import { getLookupData } from "./lookupData/index.js";
|
|
11
11
|
import { stagingPath } from "./output.js";
|
|
12
|
-
import { decodeResponseBody, getDispatcher, isVerbose, JSON_TOTAL_FACTOR, logTiming, markAuthReplay, PAGE_CONCURRENCY, parseRetryAfterMs, quoteBigIntFields, runInOrder, TRANSFER_TOTAL_FACTOR, withRetry } from "./transport.js";
|
|
12
|
+
import { decodeResponseBody, getDispatcher, isVerbose, JSON_TOTAL_FACTOR, logTiming, markAuthReplay, noteRequestSent, PAGE_CONCURRENCY, parseRetryAfterMs, quoteBigIntFields, runInOrder, TRANSFER_TOTAL_FACTOR, withRetry } from "./transport.js";
|
|
13
13
|
import { attachRowSink } from "./rowSink.js";
|
|
14
14
|
import { markIncomplete } from "./exitStatus.js";
|
|
15
|
+
import { isDryRun, stopForDryRun } from "./dryRun.js";
|
|
15
16
|
const AUTH_RETRY_CODES = new Set(["8000014", "8000015", "0000001008", "999002"]);
|
|
16
17
|
function hasExpectedShape(expects, payload) {
|
|
17
18
|
if (expects === "array")
|
|
18
19
|
return Array.isArray(payload);
|
|
20
|
+
if (expects === "object")
|
|
21
|
+
return Boolean(payload && typeof payload === "object" && !Array.isArray(payload));
|
|
19
22
|
return Boolean(payload && typeof payload === "object" && Array.isArray(payload.list));
|
|
20
23
|
}
|
|
21
24
|
const PROBE_ABOVE_CREDITS = 50;
|
|
@@ -79,7 +82,7 @@ export class GangtiseClient {
|
|
|
79
82
|
return this.refreshPromise;
|
|
80
83
|
}
|
|
81
84
|
async doTokenRefresh() {
|
|
82
|
-
const credentials = requireAccessCredentials(this.config.accessKey, this.config.secretKey);
|
|
85
|
+
const credentials = isDryRun() ? { accessKey: "", secretKey: "" } : requireAccessCredentials(this.config.accessKey, this.config.secretKey);
|
|
83
86
|
const envelope = await this.requestJson(ENDPOINTS["auth.login"], {
|
|
84
87
|
accessKey: credentials.accessKey,
|
|
85
88
|
secretKey: credentials.secretKey,
|
|
@@ -472,6 +475,8 @@ export class GangtiseClient {
|
|
|
472
475
|
if (endpoint.path.startsWith('/guide/')) {
|
|
473
476
|
return this.readLocalLookup(endpoint);
|
|
474
477
|
}
|
|
478
|
+
if (isDryRun())
|
|
479
|
+
return stopForDryRun({ endpoint, url: this.buildUrl(endpoint.path), body });
|
|
475
480
|
const dispatcher = getDispatcher();
|
|
476
481
|
const url = this.buildUrl(endpoint.path);
|
|
477
482
|
const authState = {};
|
|
@@ -488,6 +493,7 @@ export class GangtiseClient {
|
|
|
488
493
|
usedAuthorization = await this.getAuthorizationHeader();
|
|
489
494
|
headers.Authorization = usedAuthorization;
|
|
490
495
|
}
|
|
496
|
+
noteRequestSent();
|
|
491
497
|
const startedAt = Date.now();
|
|
492
498
|
const response = await request(url, {
|
|
493
499
|
method: endpoint.method,
|
|
@@ -562,6 +568,10 @@ export class GangtiseClient {
|
|
|
562
568
|
if (!endpoint || endpoint.kind !== 'upload') {
|
|
563
569
|
throw new ApiError(`Not an upload endpoint: ${endpointKey}`);
|
|
564
570
|
}
|
|
571
|
+
if (isDryRun()) {
|
|
572
|
+
const sent = Object.fromEntries(Object.entries(fields ?? {}).filter(([, value]) => value !== undefined));
|
|
573
|
+
return stopForDryRun({ endpoint, url: this.buildUrl(endpoint.path), body: sent, file: { name: file.filename, bytes: file.data.byteLength } });
|
|
574
|
+
}
|
|
565
575
|
const form = new FormData();
|
|
566
576
|
form.append('file', new Blob([file.data], { type: file.contentType ?? 'application/octet-stream' }), file.filename);
|
|
567
577
|
for (const [name, value] of Object.entries(fields ?? {})) {
|
|
@@ -580,6 +590,7 @@ export class GangtiseClient {
|
|
|
580
590
|
const timeoutMs = resolveTimeoutMs(this.config.timeoutMs, endpoint);
|
|
581
591
|
return withRetry(async () => {
|
|
582
592
|
const authorization = await this.getAuthorizationHeader();
|
|
593
|
+
noteRequestSent();
|
|
583
594
|
const startedAt = Date.now();
|
|
584
595
|
const signal = AbortSignal.timeout(timeoutMs * TRANSFER_TOTAL_FACTOR);
|
|
585
596
|
let currentUrl = url;
|
|
@@ -621,7 +632,8 @@ export class GangtiseClient {
|
|
|
621
632
|
}
|
|
622
633
|
const contentType = Array.isArray(response.headers['content-type']) ? response.headers['content-type'][0] : response.headers['content-type'];
|
|
623
634
|
const retryAfterMs = parseRetryAfterMs(response.headers['retry-after'], Date.now());
|
|
624
|
-
|
|
635
|
+
const rawFile = endpoint.fileOnSuccess === true && response.statusCode < 300;
|
|
636
|
+
if (!rawFile && contentType?.includes('application/json')) {
|
|
625
637
|
const text = await response.body.text();
|
|
626
638
|
logTiming(`${endpoint.method} ${endpoint.path} (json)`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
|
|
627
639
|
let parsed;
|
|
@@ -650,7 +662,7 @@ export class GangtiseClient {
|
|
|
650
662
|
}
|
|
651
663
|
return { text: JSON.stringify(data, null, 2), contentType };
|
|
652
664
|
}
|
|
653
|
-
if (contentType?.includes('text/plain') || contentType?.includes('text/html')) {
|
|
665
|
+
if (!rawFile && (contentType?.includes('text/plain') || contentType?.includes('text/html'))) {
|
|
654
666
|
const text = await response.body.text();
|
|
655
667
|
logTiming(`${endpoint.method} ${endpoint.path} (text)`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
|
|
656
668
|
if (response.statusCode >= 400) {
|
|
@@ -730,6 +742,11 @@ export class GangtiseClient {
|
|
|
730
742
|
if (endpoint.kind === 'upload') {
|
|
731
743
|
throw new ValidationError(`${endpointKey} takes a file upload — use its dedicated command ('gangtise tool file-parse --file <path>' / 'gangtise vault drive-upload --file <path>'); 'raw call' cannot send files`);
|
|
732
744
|
}
|
|
745
|
+
if (isDryRun() && !endpoint.path.startsWith('/guide/')) {
|
|
746
|
+
const url = this.buildUrl(endpoint.path);
|
|
747
|
+
Object.entries(query ?? {}).forEach(([key, value]) => url.searchParams.set(key, String(value)));
|
|
748
|
+
return stopForDryRun({ endpoint, url, body });
|
|
749
|
+
}
|
|
733
750
|
if (endpoint.kind === 'download') {
|
|
734
751
|
return this.download(endpoint, query ?? {}, options, body);
|
|
735
752
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
|
-
import { dirname, extname } from "node:path";
|
|
3
|
-
import { DownloadError } from "./errors.js";
|
|
2
|
+
import { dirname, extname, join, sep } from "node:path";
|
|
3
|
+
import { DownloadError, ValidationError } from "./errors.js";
|
|
4
4
|
import { saveOutputIfNeeded, stagingPath, trackClaim } from "./output.js";
|
|
5
|
+
import { assertNotStatePath } from "./statePaths.js";
|
|
5
6
|
import { extractTitles, lookupTitleCache, readTitleCache, TITLE_LOOKUP_SIZE, writeTitleCache } from "./titleCache.js";
|
|
6
7
|
function sanitizeFilename(name) {
|
|
7
8
|
return name.replace(/[/\\:*?"<>|\u0000-\u001f]/g, "_");
|
|
@@ -147,7 +148,7 @@ async function downloadUrlTo(url, outputPath) {
|
|
|
147
148
|
const { pipeline } = await import("node:stream/promises");
|
|
148
149
|
const { dirname } = await import("node:path");
|
|
149
150
|
const { request } = await import("undici");
|
|
150
|
-
const { getDispatcher, logTiming, TRANSFER_TOTAL_FACTOR, withRetry } = await import("./transport.js");
|
|
151
|
+
const { getDispatcher, logTiming, noteRequestSent, TRANSFER_TOTAL_FACTOR, withRetry } = await import("./transport.js");
|
|
151
152
|
const { loadConfig } = await import("./config.js");
|
|
152
153
|
const timeoutMs = loadConfig().timeoutMs;
|
|
153
154
|
await fs.mkdir(dirname(outputPath), { recursive: true });
|
|
@@ -162,6 +163,7 @@ async function downloadUrlTo(url, outputPath) {
|
|
|
162
163
|
};
|
|
163
164
|
const partPath = stagingPath(outputPath);
|
|
164
165
|
await withRetry(async () => {
|
|
166
|
+
noteRequestSent();
|
|
165
167
|
const startedAt = Date.now();
|
|
166
168
|
const requestOptions = {
|
|
167
169
|
method: "GET",
|
|
@@ -206,6 +208,13 @@ async function downloadUrlTo(url, outputPath) {
|
|
|
206
208
|
throw error;
|
|
207
209
|
}
|
|
208
210
|
}
|
|
211
|
+
export async function assertOutputFilePath(output) {
|
|
212
|
+
const endsAsDirectory = output.endsWith("/") || output.endsWith(sep);
|
|
213
|
+
if (endsAsDirectory || (await fs.stat(output).catch(() => undefined))?.isDirectory()) {
|
|
214
|
+
throw new ValidationError(`--output ${output} is a directory: pass a file path (e.g. ${join(output, "<name>")}), or omit --output for an automatic name in the current directory`);
|
|
215
|
+
}
|
|
216
|
+
await assertNotStatePath(output, "--output");
|
|
217
|
+
}
|
|
209
218
|
export function autoDownloadName(file, fallbackName) {
|
|
210
219
|
return truncateFilename(file.filename ? sanitizeFilename(file.filename) : sanitizeFilename(fallbackName) + extFromContentType(file.contentType));
|
|
211
220
|
}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { ValidationError } from "./errors.js";
|
|
4
|
+
import { assertNotStatePath } from "./statePaths.js";
|
|
4
5
|
export const DRIVE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
|
5
6
|
export const DRIVE_NAME_MAX = 200;
|
|
6
7
|
export async function uploadDriveFile(client, filePath, options) {
|
|
7
|
-
const
|
|
8
|
+
const shownAs = path.resolve(filePath);
|
|
9
|
+
let resolved;
|
|
8
10
|
let stat;
|
|
9
11
|
try {
|
|
12
|
+
resolved = await fs.realpath(filePath);
|
|
10
13
|
stat = await fs.stat(resolved);
|
|
11
14
|
}
|
|
12
15
|
catch {
|
|
@@ -14,12 +17,13 @@ export async function uploadDriveFile(client, filePath, options) {
|
|
|
14
17
|
}
|
|
15
18
|
if (!stat.isFile())
|
|
16
19
|
throw new ValidationError(`Not a file: ${filePath}`);
|
|
20
|
+
await assertNotStatePath(resolved, "--file");
|
|
17
21
|
if (stat.size === 0)
|
|
18
22
|
throw new ValidationError(`File is empty: ${filePath}`);
|
|
19
23
|
if (stat.size > DRIVE_UPLOAD_MAX_BYTES) {
|
|
20
24
|
throw new ValidationError(`File is ${(stat.size / 1024 / 1024).toFixed(1)}MB — the drive accepts at most 100MB per file`);
|
|
21
25
|
}
|
|
22
|
-
const filename = path.basename(
|
|
26
|
+
const filename = path.basename(shownAs);
|
|
23
27
|
const name = options.title ?? filename;
|
|
24
28
|
if (name.length > DRIVE_NAME_MAX) {
|
|
25
29
|
throw new ValidationError(`File name is ${name.length} characters — the drive accepts at most ${DRIVE_NAME_MAX} (an emoji counts as 2); pass a shorter --title`);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { removeStagingFiles } from "./output.js";
|
|
2
|
+
let dryRun = false;
|
|
3
|
+
let shown = false;
|
|
4
|
+
export function setDryRun(enabled) {
|
|
5
|
+
dryRun = enabled;
|
|
6
|
+
}
|
|
7
|
+
export function isDryRun() {
|
|
8
|
+
return dryRun;
|
|
9
|
+
}
|
|
10
|
+
const toCents = (credits) => Math.round(credits * 100) / 100;
|
|
11
|
+
function maxCredits(endpoint, body) {
|
|
12
|
+
const billing = endpoint.billing;
|
|
13
|
+
if (!billing)
|
|
14
|
+
return undefined;
|
|
15
|
+
const fields = body && typeof body === "object" ? body : {};
|
|
16
|
+
if (billing.per === "call" || billing.per === "document")
|
|
17
|
+
return billing.price;
|
|
18
|
+
if (endpoint.estimateBilledUnits) {
|
|
19
|
+
try {
|
|
20
|
+
return toCents(endpoint.estimateBilledUnits(fields) * billing.price);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (billing.per === "row" && endpoint.pagination?.enabled)
|
|
27
|
+
return typeof fields.size === "number" ? toCents(fields.size * billing.price) : undefined;
|
|
28
|
+
if (!billing.maxUnits)
|
|
29
|
+
return undefined;
|
|
30
|
+
const asked = billing.unitsFrom?.reduce((n, key) => n + (Array.isArray(fields[key]) ? fields[key].length : 0), 0);
|
|
31
|
+
return toCents(Math.min(billing.maxUnits, asked || billing.maxUnits) * billing.price);
|
|
32
|
+
}
|
|
33
|
+
export function describeRequest(plan) {
|
|
34
|
+
const { endpoint } = plan;
|
|
35
|
+
return {
|
|
36
|
+
dryRun: true,
|
|
37
|
+
endpoint: endpoint.key,
|
|
38
|
+
method: endpoint.method,
|
|
39
|
+
url: plan.url.toString(),
|
|
40
|
+
body: endpoint.key === "auth.login" ? "(credentials not shown)" : plan.body ?? null,
|
|
41
|
+
file: plan.file,
|
|
42
|
+
pagination: endpoint.pagination?.enabled
|
|
43
|
+
? `The CLI pages this list itself, at most ${endpoint.pagination.maxPageSize} rows a request, until size rows or the total; without size it fetches every row`
|
|
44
|
+
: undefined,
|
|
45
|
+
billing: endpoint.billing
|
|
46
|
+
? { per: endpoint.billing.per, price: endpoint.billing.price, ...(Number.isFinite(endpoint.billing.maxUnits) ? { maxUnits: endpoint.billing.maxUnits } : {}) }
|
|
47
|
+
: null,
|
|
48
|
+
maxCredits: maxCredits(endpoint, plan.body),
|
|
49
|
+
needsYes: endpoint.destructive ? true : undefined,
|
|
50
|
+
note: "Nothing was sent. Only the command's first request is shown; any later request depends on its answer.",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function describeNoRequest(output) {
|
|
54
|
+
return {
|
|
55
|
+
dryRun: true,
|
|
56
|
+
endpoint: null,
|
|
57
|
+
output: output ?? null,
|
|
58
|
+
note: "This command sends no request. Under --dry-run it prints only this and writes no file.",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export function stopForDryRun(plan) {
|
|
62
|
+
if (!shown) {
|
|
63
|
+
shown = true;
|
|
64
|
+
removeStagingFiles();
|
|
65
|
+
process.stdout.write(`${JSON.stringify(describeRequest(plan), null, 2)}\n`, () => process.exit(0));
|
|
66
|
+
}
|
|
67
|
+
return new Promise(() => { });
|
|
68
|
+
}
|