@dac-tech-team-tw/itsaplan-cli 0.0.0-stage → 0.1.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/dist/http.js ADDED
@@ -0,0 +1,306 @@
1
+ import { CliError } from "./contracts.js";
2
+ const secrets = new Set();
3
+ export function registerSecret(token) {
4
+ if (token)
5
+ secrets.add(token);
6
+ }
7
+ export function redact(value) {
8
+ const currentSecrets = new Set(secrets);
9
+ if (process.env.ITSAPLAN_API_KEY)
10
+ currentSecrets.add(process.env.ITSAPLAN_API_KEY);
11
+ for (let index = 0; index < process.argv.length; index++) {
12
+ const arg = process.argv[index];
13
+ if (arg === "--api-key" && process.argv[index + 1])
14
+ currentSecrets.add(process.argv[index + 1]);
15
+ else if (arg?.startsWith("--api-key="))
16
+ currentSecrets.add(arg.slice("--api-key=".length));
17
+ }
18
+ const ordered = [...currentSecrets]
19
+ .filter(Boolean)
20
+ .sort((a, b) => b.length - a.length);
21
+ const seen = new WeakSet();
22
+ function visit(entry) {
23
+ if (typeof entry === "string") {
24
+ let text = entry;
25
+ for (const token of ordered)
26
+ text = text.split(token).join("[REDACTED]");
27
+ return text;
28
+ }
29
+ if (entry === null || typeof entry !== "object")
30
+ return entry;
31
+ if (seen.has(entry))
32
+ return "[Circular]";
33
+ seen.add(entry);
34
+ let result;
35
+ if (Array.isArray(entry))
36
+ result = entry.map(visit);
37
+ else {
38
+ const object = {};
39
+ for (const [key, content] of Object.entries(entry)) {
40
+ const name = key.replace(/[-_\s]/gu, "").toLowerCase();
41
+ const safeKey = String(visit(key));
42
+ const availability = typeof content === "boolean" &&
43
+ /^(?:tokenavailable|hastoken|apikeyavailable|apikeypresent|keypresent)$/u.test(name);
44
+ Object.defineProperty(object, safeKey, {
45
+ value: !availability &&
46
+ /token|secret|password|credential|authorization|cookie|apikey|privatekey/u.test(name)
47
+ ? "[REDACTED]"
48
+ : visit(content),
49
+ enumerable: true,
50
+ configurable: true,
51
+ writable: true,
52
+ });
53
+ }
54
+ result = object;
55
+ }
56
+ seen.delete(entry);
57
+ return result;
58
+ }
59
+ return visit(value);
60
+ }
61
+ export function validateRelativePath(input) {
62
+ if (!input ||
63
+ /^[a-z][a-z0-9+.-]*:/iu.test(input) ||
64
+ input.startsWith("//") ||
65
+ /[\\\u0000-\u0020\u007f#]/u.test(input))
66
+ throw new CliError("INVALID_API_PATH", "Use a relative API path without an origin, fragment, whitespace, or backslash.");
67
+ const queryAt = input.indexOf("?");
68
+ const pathname = queryAt < 0 ? input : input.slice(0, queryAt);
69
+ let decoded = pathname;
70
+ try {
71
+ for (let iteration = 0; iteration < 16; iteration++) {
72
+ if (/%(?:2f|5c)/iu.test(decoded) ||
73
+ /[\\\u0000-\u0020\u007f]/u.test(decoded) ||
74
+ decoded.startsWith("//") ||
75
+ decoded
76
+ .split("/")
77
+ .some((segment) => segment === "." || segment === "..") ||
78
+ /^[a-z][a-z0-9+.-]*:/iu.test(decoded))
79
+ throw new Error("Unsafe path");
80
+ const next = decodeURIComponent(decoded);
81
+ if (next === decoded)
82
+ break;
83
+ decoded = next;
84
+ if (iteration === 15)
85
+ throw new Error("Excessive encoding");
86
+ }
87
+ if (queryAt >= 0) {
88
+ const query = decodeURIComponent(input.slice(queryAt + 1));
89
+ if (/[\u0000-\u001f\u007f]/u.test(query))
90
+ throw new Error("Unsafe query");
91
+ }
92
+ }
93
+ catch {
94
+ throw new CliError("INVALID_API_PATH", "API path contains traversal, encoded separators, controls, or malformed percent encoding.");
95
+ }
96
+ return input.startsWith("/") ? input : `/${input}`;
97
+ }
98
+ const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
99
+ const rejectedStatuses = new Set([
100
+ 400, 401, 403, 404, 405, 409, 413, 415, 422, 429,
101
+ ]);
102
+ export function classifyWriteFailure(error) {
103
+ const details = error instanceof CliError ? error.details : {};
104
+ if (details.outcome !== undefined)
105
+ return details.outcome === "not_applied" ? "not_applied" : "unknown";
106
+ return typeof details.status === "number" &&
107
+ rejectedStatuses.has(details.status)
108
+ ? "not_applied"
109
+ : "unknown";
110
+ }
111
+ export class HttpClient {
112
+ apiUrl;
113
+ token;
114
+ constructor(apiUrl, token) {
115
+ this.apiUrl = apiUrl;
116
+ this.token = token;
117
+ if (token)
118
+ registerSecret(token);
119
+ }
120
+ async request(input, options = {}) {
121
+ const method = (options.method ?? "GET").toUpperCase();
122
+ const write = !["GET", "HEAD"].includes(method);
123
+ let url;
124
+ let headers;
125
+ let body;
126
+ try {
127
+ if (!["GET", "HEAD", "POST", "PATCH", "PUT", "DELETE"].includes(method))
128
+ throw new CliError("INVALID_HTTP_METHOD", "Supported methods: GET, HEAD, POST, PATCH, PUT, DELETE.");
129
+ const path = validateRelativePath(input);
130
+ const base = new URL(this.apiUrl);
131
+ if (base.username ||
132
+ base.password ||
133
+ base.search ||
134
+ base.hash ||
135
+ !["https:", "http:"].includes(base.protocol))
136
+ throw new CliError("INVALID_API_URL", "Invalid API base URL.");
137
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(base.hostname);
138
+ if (base.protocol !== "https:" && !local)
139
+ throw new CliError("INVALID_API_URL", "HTTPS is required except on localhost.");
140
+ url = new URL(`${base.toString().replace(/\/+$/u, "")}${path}`);
141
+ if (url.origin !== base.origin ||
142
+ !url.pathname.startsWith(`${base.pathname.replace(/\/+$/u, "")}/`))
143
+ throw new CliError("INVALID_API_PATH", "API path must remain inside the configured endpoint.");
144
+ for (const [key, value] of Object.entries(options.query ?? {}))
145
+ if (value !== undefined)
146
+ url.searchParams.set(key, String(value));
147
+ headers = new Headers({ accept: "application/json" });
148
+ if (this.token !== undefined) {
149
+ if (!this.token || /[\r\n\0]/u.test(this.token))
150
+ throw new CliError("INVALID_TOKEN", "API key must be nonempty and contain no line breaks.");
151
+ headers.set("x-api-key", this.token);
152
+ }
153
+ if (options.body !== undefined) {
154
+ if (!write)
155
+ throw new CliError("INVALID_REQUEST_BODY", "GET and HEAD requests do not accept a body.");
156
+ body = JSON.stringify(options.body);
157
+ if (body === undefined)
158
+ throw new CliError("INVALID_REQUEST_BODY", "Request body must be JSON serializable.");
159
+ headers.set("content-type", "application/json");
160
+ }
161
+ }
162
+ catch (error) {
163
+ if (error instanceof CliError) {
164
+ if (write)
165
+ Object.assign(error.details, {
166
+ outcome: "not_applied",
167
+ verification: "not_run",
168
+ retryable: false,
169
+ });
170
+ throw error;
171
+ }
172
+ throw new CliError("INVALID_REQUEST", "Cannot construct a valid HTTP request.", 2, write
173
+ ? {
174
+ outcome: "not_applied",
175
+ verification: "not_run",
176
+ retryable: false,
177
+ }
178
+ : {});
179
+ }
180
+ const controller = new AbortController();
181
+ const timer = setTimeout(() => controller.abort(), 30_000);
182
+ try {
183
+ const response = await fetch(url, {
184
+ method,
185
+ headers,
186
+ body,
187
+ redirect: "manual",
188
+ signal: controller.signal,
189
+ });
190
+ if (response.status >= 300 && response.status < 400)
191
+ throw new CliError(write ? "WRITE_OUTCOME_UNKNOWN" : "REDIRECT_REJECTED", "API redirects are not followed; confirm the configured API URL.", write ? 6 : 1, {
192
+ status: response.status,
193
+ retryable: false,
194
+ ...(write ? { outcome: "unknown", verification: "not_run" } : {}),
195
+ });
196
+ let data;
197
+ const text = method === "HEAD" ? "" : await response.text();
198
+ if (text) {
199
+ try {
200
+ data = JSON.parse(text);
201
+ }
202
+ catch {
203
+ if (response.ok)
204
+ throw new CliError(write ? "WRITE_OUTCOME_UNKNOWN" : "INVALID_API_RESPONSE", write
205
+ ? "Write response is not valid JSON; inspect the resource or feed before resending."
206
+ : "API response is not valid JSON.", write ? 6 : 1, {
207
+ status: response.status,
208
+ retryable: false,
209
+ ...(write
210
+ ? { outcome: "unknown", verification: "not_run" }
211
+ : {}),
212
+ });
213
+ }
214
+ }
215
+ else
216
+ data = null;
217
+ if (!response.ok) {
218
+ const refusal = rejectedStatuses.has(response.status);
219
+ const errorData = record(data) && record(data.error) ? data.error : data;
220
+ const apiCode = record(errorData) && typeof errorData.code === "string"
221
+ ? errorData.code
222
+ : undefined;
223
+ const rawMessage = record(errorData) && typeof errorData.message === "string"
224
+ ? errorData.message
225
+ : record(data) && typeof data.error === "string"
226
+ ? data.error
227
+ : `API returned HTTP ${response.status}.`;
228
+ // Upstream has no ambiguity code; recognize only its exact bare-key
229
+ // lookup response, never unrelated 409s or a provider-coded conflict.
230
+ const projectPath = response.status === 409 && method === "GET" && apiCode === undefined
231
+ ? /^\/?projects\/([^/?]+)(?:\/|\?|$)/u.exec(input)
232
+ : null;
233
+ const projectRef = projectPath
234
+ ? decodeURIComponent(projectPath[1])
235
+ : undefined;
236
+ const ambiguousProject = projectRef !== undefined &&
237
+ !projectRef.includes(".") &&
238
+ rawMessage ===
239
+ `Several of your teams have a project '${projectRef}'. Name it with its team, as '<team>.${projectRef}'.`;
240
+ const statusExit = response.status === 401
241
+ ? 3
242
+ : response.status === 403
243
+ ? 4
244
+ : ambiguousProject
245
+ ? 2
246
+ : response.status === 409
247
+ ? 5
248
+ : response.status === 400 || response.status === 422
249
+ ? 2
250
+ : 1;
251
+ const code = response.status === 401
252
+ ? "AUTHENTICATION_FAILED"
253
+ : response.status === 403
254
+ ? "PERMISSION_DENIED"
255
+ : ambiguousProject
256
+ ? "AMBIGUOUS_PROJECT"
257
+ : response.status === 409
258
+ ? "CONFLICT"
259
+ : response.status === 400 || response.status === 422
260
+ ? "INVALID_API_INPUT"
261
+ : "API_ERROR";
262
+ throw new CliError(write && !refusal ? "WRITE_OUTCOME_UNKNOWN" : code, String(redact(rawMessage)), write && !refusal ? 6 : statusExit, {
263
+ status: response.status,
264
+ ...(apiCode ? { apiCode: redact(apiCode) } : {}),
265
+ retryable: !write && (response.status === 429 || response.status >= 500),
266
+ ...(write
267
+ ? {
268
+ outcome: refusal ? "not_applied" : "unknown",
269
+ verification: "not_run",
270
+ }
271
+ : {}),
272
+ });
273
+ }
274
+ if (url.pathname.replace(/\/$/u, "") ===
275
+ `${new URL(this.apiUrl).pathname.replace(/\/$/u, "")}/me`) {
276
+ if (record(data) && data.authenticated === false)
277
+ throw new CliError("AUTHENTICATION_FAILED", "API key was not accepted or the account is inactive.", 3, { status: response.status, retryable: false });
278
+ if (!record(data) || data.authenticated !== true || !record(data.user))
279
+ throw new CliError("UNSUPPORTED_API_RESPONSE", "The /me response does not contain an authenticated identity.", 1, { status: response.status, retryable: false });
280
+ }
281
+ return redact(data);
282
+ }
283
+ catch (error) {
284
+ if (error instanceof CliError)
285
+ throw error;
286
+ const timeout = controller.signal.aborted ||
287
+ (error instanceof Error &&
288
+ ["TimeoutError", "AbortError"].includes(error.name));
289
+ throw new CliError(write
290
+ ? "WRITE_OUTCOME_UNKNOWN"
291
+ : timeout
292
+ ? "REQUEST_TIMEOUT"
293
+ : "NETWORK_ERROR", write
294
+ ? "Write outcome is unknown; inspect the resource or feed before resending."
295
+ : timeout
296
+ ? "API request timed out."
297
+ : "API request failed; check network access and the configured API URL.", write ? 6 : 1, {
298
+ retryable: !write,
299
+ ...(write ? { outcome: "unknown", verification: "not_run" } : {}),
300
+ });
301
+ }
302
+ finally {
303
+ clearTimeout(timer);
304
+ }
305
+ }
306
+ }
@@ -0,0 +1,61 @@
1
+ import { type Actor, type Context, type ObjectData, type Result } from "./contracts.js";
2
+ export interface IssueOptions {
3
+ id?: string;
4
+ project?: string;
5
+ comments?: boolean;
6
+ limit?: string | number;
7
+ cursor?: string;
8
+ search?: string;
9
+ assignee?: string;
10
+ title?: string;
11
+ body?: string;
12
+ bodyFile?: string;
13
+ state?: string;
14
+ stateType?: string;
15
+ columnId?: string;
16
+ removeAssignee?: boolean;
17
+ priority?: string;
18
+ removePriority?: boolean;
19
+ type?: string;
20
+ removeType?: boolean;
21
+ addLabel?: string[];
22
+ removeLabel?: string[];
23
+ replyTo?: string;
24
+ agent?: string;
25
+ human?: boolean;
26
+ dryRun?: boolean;
27
+ }
28
+ type RecordValue = Record<string, unknown>;
29
+ interface Issue extends RecordValue {
30
+ id: number;
31
+ projectId: number;
32
+ identifier: string;
33
+ sequenceNumber: number;
34
+ }
35
+ interface Project {
36
+ project: RecordValue;
37
+ columns: RecordValue[];
38
+ issueTypes: RecordValue[];
39
+ labels: RecordValue[];
40
+ assignees: RecordValue[];
41
+ customFields: RecordValue[];
42
+ }
43
+ declare const positive: (value: unknown) => value is number;
44
+ declare function object(value: unknown, name: string): RecordValue;
45
+ declare function objects(value: unknown, name: string): RecordValue[];
46
+ declare function integer(value: string | number, name: string, max?: number): number;
47
+ declare function projectRef(value: string): string;
48
+ declare function publicData(value: unknown): unknown;
49
+ declare function getProject(ctx: Context, ref: string): Promise<Project>;
50
+ declare function resolve(ctx: Context, identifier: string | undefined, options: IssueOptions): Promise<{
51
+ item: Issue;
52
+ ref: string | undefined;
53
+ }>;
54
+ declare function setupFor(ctx: Context, item: Issue, ref: string | undefined): Promise<Project>;
55
+ declare function actorFor(options: IssueOptions): Actor;
56
+ declare function readBody(options: IssueOptions): Promise<string | undefined>;
57
+ declare function me(ctx: Context): Promise<RecordValue>;
58
+ declare function writeFailure(error: unknown, data: ObjectData, actor: Actor, resource?: string): Result;
59
+ declare function verificationFailed(data: ObjectData, actor: Actor, resource?: string): Result;
60
+ export declare function runIssue(ctx: Context, action: string, identifier: string | undefined, options: IssueOptions): Promise<Result>;
61
+ export { actorFor, readBody, me, integer, object, objects, positive, projectRef, publicData, getProject, resolve, setupFor, writeFailure, verificationFailed, type Project, };