@dac-tech-team-tw/itsaplan-cli 0.0.0-stage → 0.2.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 +244 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +444 -0
- package/dist/commands.d.ts +15 -0
- package/dist/commands.js +681 -0
- package/dist/config.d.ts +32 -0
- package/dist/config.js +608 -0
- package/dist/contracts.d.ts +47 -0
- package/dist/contracts.js +14 -0
- package/dist/development.d.ts +9 -0
- package/dist/development.js +220 -0
- package/dist/documents.d.ts +9 -0
- package/dist/documents.js +256 -0
- package/dist/http.d.ts +11 -0
- package/dist/http.js +306 -0
- package/dist/issues.d.ts +63 -0
- package/dist/issues.js +800 -0
- package/dist/output.d.ts +12 -0
- package/dist/output.js +260 -0
- package/dist/project-config.d.ts +2 -0
- package/dist/project-config.js +56 -0
- package/dist/writes.d.ts +8 -0
- package/dist/writes.js +57 -0
- package/package.json +45 -4
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
|
+
}
|
package/dist/issues.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
includeState?: boolean;
|
|
7
|
+
outputSelected?: boolean;
|
|
8
|
+
limit?: string | number;
|
|
9
|
+
cursor?: string;
|
|
10
|
+
search?: string;
|
|
11
|
+
assignee?: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
body?: string;
|
|
14
|
+
bodyFile?: string;
|
|
15
|
+
state?: string;
|
|
16
|
+
stateType?: string;
|
|
17
|
+
columnId?: string;
|
|
18
|
+
removeAssignee?: boolean;
|
|
19
|
+
priority?: string;
|
|
20
|
+
removePriority?: boolean;
|
|
21
|
+
type?: string;
|
|
22
|
+
removeType?: boolean;
|
|
23
|
+
addLabel?: string[];
|
|
24
|
+
removeLabel?: string[];
|
|
25
|
+
replyTo?: string;
|
|
26
|
+
agent?: string;
|
|
27
|
+
human?: boolean;
|
|
28
|
+
dryRun?: boolean;
|
|
29
|
+
}
|
|
30
|
+
type RecordValue = Record<string, unknown>;
|
|
31
|
+
interface Issue extends RecordValue {
|
|
32
|
+
id: number;
|
|
33
|
+
projectId: number;
|
|
34
|
+
identifier: string;
|
|
35
|
+
sequenceNumber: number;
|
|
36
|
+
}
|
|
37
|
+
interface Project {
|
|
38
|
+
project: RecordValue;
|
|
39
|
+
columns: RecordValue[];
|
|
40
|
+
issueTypes: RecordValue[];
|
|
41
|
+
labels: RecordValue[];
|
|
42
|
+
assignees: RecordValue[];
|
|
43
|
+
customFields: RecordValue[];
|
|
44
|
+
}
|
|
45
|
+
declare const positive: (value: unknown) => value is number;
|
|
46
|
+
declare function object(value: unknown, name: string): RecordValue;
|
|
47
|
+
declare function objects(value: unknown, name: string): RecordValue[];
|
|
48
|
+
declare function integer(value: string | number, name: string, max?: number): number;
|
|
49
|
+
declare function projectRef(value: string): string;
|
|
50
|
+
declare function publicData(value: unknown): unknown;
|
|
51
|
+
declare function getProject(ctx: Context, ref: string): Promise<Project>;
|
|
52
|
+
declare function resolve(ctx: Context, identifier: string | undefined, options: IssueOptions): Promise<{
|
|
53
|
+
item: Issue;
|
|
54
|
+
ref: string | undefined;
|
|
55
|
+
}>;
|
|
56
|
+
declare function setupFor(ctx: Context, item: Issue, ref: string | undefined): Promise<Project>;
|
|
57
|
+
declare function actorFor(options: IssueOptions): Actor;
|
|
58
|
+
declare function readBody(options: IssueOptions): Promise<string | undefined>;
|
|
59
|
+
declare function me(ctx: Context): Promise<RecordValue>;
|
|
60
|
+
declare function writeFailure(error: unknown, data: ObjectData, actor: Actor, resource?: string): Result;
|
|
61
|
+
declare function verificationFailed(data: ObjectData, actor: Actor, resource?: string): Result;
|
|
62
|
+
export declare function runIssue(ctx: Context, action: string, identifier: string | undefined, options: IssueOptions): Promise<Result>;
|
|
63
|
+
export { actorFor, readBody, me, integer, object, objects, positive, projectRef, publicData, getProject, resolve, setupFor, writeFailure, verificationFailed, type Project, };
|