@ycadence/cli 0.1.0 → 0.2.1
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 +39 -14
- package/dist/bin.js +3642 -327
- package/package.json +2 -2
package/dist/bin.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/bin.ts
|
|
4
|
-
import {
|
|
4
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
5
|
+
import { readFile as readFile3 } from "node:fs/promises";
|
|
6
|
+
import { createInterface } from "node:readline";
|
|
7
|
+
|
|
8
|
+
// src/runner.ts
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
5
10
|
|
|
6
11
|
// ../core/src/env.ts
|
|
7
12
|
var ENV_PREFIX = "YCADENCE_";
|
|
@@ -20,129 +25,370 @@ function readEnv(name, env = processEnv()) {
|
|
|
20
25
|
return;
|
|
21
26
|
}
|
|
22
27
|
|
|
23
|
-
// ../sdk/src/
|
|
28
|
+
// ../sdk/src/errors.ts
|
|
24
29
|
class YCadenceError extends Error {
|
|
25
30
|
status;
|
|
26
31
|
code;
|
|
27
32
|
issues;
|
|
28
|
-
|
|
33
|
+
requestId;
|
|
34
|
+
details;
|
|
35
|
+
retryAfter;
|
|
36
|
+
method;
|
|
37
|
+
path;
|
|
38
|
+
constructor(status, code, message, issues, extras = {}) {
|
|
29
39
|
super(message);
|
|
30
40
|
this.status = status;
|
|
31
41
|
this.code = code;
|
|
32
42
|
this.issues = issues;
|
|
33
43
|
this.name = "YCadenceError";
|
|
44
|
+
this.requestId = extras.requestId;
|
|
45
|
+
this.details = extras.details;
|
|
46
|
+
this.retryAfter = extras.retryAfter;
|
|
47
|
+
this.method = extras.method;
|
|
48
|
+
this.path = extras.path;
|
|
34
49
|
}
|
|
35
50
|
get retryable() {
|
|
36
|
-
return this.status === 429 || this.status >= 500;
|
|
51
|
+
return this.status === 0 || this.status === 408 || this.status === 429 || this.status >= 500;
|
|
37
52
|
}
|
|
38
53
|
}
|
|
39
54
|
|
|
55
|
+
class YCadenceValidationError extends YCadenceError {
|
|
56
|
+
name = "YCadenceValidationError";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
class YCadencePermissionError extends YCadenceError {
|
|
60
|
+
name = "YCadencePermissionError";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
class YCadenceNotFoundError extends YCadenceError {
|
|
64
|
+
name = "YCadenceNotFoundError";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
class YCadenceConflictError extends YCadenceError {
|
|
68
|
+
name = "YCadenceConflictError";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
class YCadencePreconditionError extends YCadenceError {
|
|
72
|
+
name = "YCadencePreconditionError";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
class YCadenceRateLimitError extends YCadenceError {
|
|
76
|
+
name = "YCadenceRateLimitError";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
class YCadenceNetworkError extends YCadenceError {
|
|
80
|
+
name = "YCadenceNetworkError";
|
|
81
|
+
}
|
|
82
|
+
function errorClassFor(status) {
|
|
83
|
+
if (status === 0 || status === 408)
|
|
84
|
+
return YCadenceNetworkError;
|
|
85
|
+
if (status === 400 || status === 422)
|
|
86
|
+
return YCadenceValidationError;
|
|
87
|
+
if (status === 401 || status === 403)
|
|
88
|
+
return YCadencePermissionError;
|
|
89
|
+
if (status === 404)
|
|
90
|
+
return YCadenceNotFoundError;
|
|
91
|
+
if (status === 409)
|
|
92
|
+
return YCadenceConflictError;
|
|
93
|
+
if (status === 412)
|
|
94
|
+
return YCadencePreconditionError;
|
|
95
|
+
if (status === 429)
|
|
96
|
+
return YCadenceRateLimitError;
|
|
97
|
+
return YCadenceError;
|
|
98
|
+
}
|
|
99
|
+
function createError(status, code, message, issues, extras = {}) {
|
|
100
|
+
const ErrorClass = errorClassFor(status);
|
|
101
|
+
return new ErrorClass(status, code, message, issues, extras);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
class RequestIntercepted extends Error {
|
|
105
|
+
request;
|
|
106
|
+
name = "RequestIntercepted";
|
|
107
|
+
constructor(request) {
|
|
108
|
+
super(`${request.method} ${request.url} was not sent.`);
|
|
109
|
+
this.request = request;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ../sdk/src/operations.ts
|
|
114
|
+
var OPERATIONS = {
|
|
115
|
+
health: { method: "GET", path: "/api/health" },
|
|
116
|
+
me: { method: "GET", path: "/api/v1/me" },
|
|
117
|
+
listProjects: { method: "GET", path: "/api/v1/projects" },
|
|
118
|
+
listWorkItems: { method: "GET", path: "/api/v1/projects/{projectId}/work-items" },
|
|
119
|
+
createWorkItem: { method: "POST", path: "/api/v1/projects/{projectId}/work-items" },
|
|
120
|
+
getWorkItem: { method: "GET", path: "/api/v1/work-items/{workItemId}" },
|
|
121
|
+
updateWorkItem: { method: "PATCH", path: "/api/v1/work-items/{workItemId}" },
|
|
122
|
+
deleteWorkItem: { method: "DELETE", path: "/api/v1/work-items/{workItemId}" },
|
|
123
|
+
listStates: { method: "GET", path: "/api/v1/projects/{projectId}/states" },
|
|
124
|
+
listLabels: { method: "GET", path: "/api/v1/projects/{projectId}/labels" },
|
|
125
|
+
createLabel: { method: "POST", path: "/api/v1/projects/{projectId}/labels" },
|
|
126
|
+
listTypes: { method: "GET", path: "/api/v1/projects/{projectId}/types" },
|
|
127
|
+
listCycles: { method: "GET", path: "/api/v1/projects/{projectId}/cycles" },
|
|
128
|
+
createCycle: { method: "POST", path: "/api/v1/projects/{projectId}/cycles" },
|
|
129
|
+
getCycleProgress: { method: "GET", path: "/api/v1/cycles/{cycleId}/progress" },
|
|
130
|
+
addWorkItemsToCycle: { method: "POST", path: "/api/v1/cycles/{cycleId}/work-items" },
|
|
131
|
+
listViews: { method: "GET", path: "/api/v1/projects/{projectId}/views" },
|
|
132
|
+
createView: { method: "POST", path: "/api/v1/projects/{projectId}/views" },
|
|
133
|
+
listIntake: { method: "GET", path: "/api/v1/projects/{projectId}/intake" },
|
|
134
|
+
submitIntake: { method: "POST", path: "/api/v1/projects/{projectId}/intake" },
|
|
135
|
+
triageIntake: { method: "POST", path: "/api/v1/intake-items/{intakeItemId}/triage" },
|
|
136
|
+
previewImport: { method: "POST", path: "/api/v1/projects/{projectId}/imports/preview" },
|
|
137
|
+
startImport: { method: "POST", path: "/api/v1/projects/{projectId}/imports" },
|
|
138
|
+
listImports: { method: "GET", path: "/api/v1/projects/{projectId}/imports" },
|
|
139
|
+
getImport: { method: "GET", path: "/api/v1/imports/{runId}" }
|
|
140
|
+
};
|
|
141
|
+
function isIdempotentMethod(method) {
|
|
142
|
+
return method === "GET" || method === "HEAD";
|
|
143
|
+
}
|
|
144
|
+
function buildPath(template, params = {}) {
|
|
145
|
+
return template.replace(/\{(\w+)\}/g, (_match, name) => {
|
|
146
|
+
const value = params[name];
|
|
147
|
+
if (value === undefined || value === "") {
|
|
148
|
+
throw new TypeError(`Missing path parameter "${name}" for ${template}.`);
|
|
149
|
+
}
|
|
150
|
+
return encodeURIComponent(value);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ../sdk/src/pagination.ts
|
|
155
|
+
async function* paginate(fetchPage, options = {}) {
|
|
156
|
+
let cursor = options.cursor;
|
|
157
|
+
let yielded = 0;
|
|
158
|
+
do {
|
|
159
|
+
const page = await fetchPage(cursor);
|
|
160
|
+
for (const item of page.items) {
|
|
161
|
+
if (options.maxItems !== undefined && yielded >= options.maxItems)
|
|
162
|
+
return;
|
|
163
|
+
yielded += 1;
|
|
164
|
+
yield item;
|
|
165
|
+
}
|
|
166
|
+
cursor = page.nextCursor ?? undefined;
|
|
167
|
+
} while (cursor !== undefined);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ../sdk/src/version.ts
|
|
171
|
+
var SDK_VERSION = "0.2.0";
|
|
172
|
+
|
|
173
|
+
// ../sdk/src/client.ts
|
|
174
|
+
var DEFAULT_RETRY = { retries: 2, baseDelayMs: 250, maxDelayMs: 8000 };
|
|
175
|
+
|
|
40
176
|
class YCadenceClient {
|
|
177
|
+
options;
|
|
41
178
|
baseUrl;
|
|
42
179
|
apiKey;
|
|
43
180
|
identityToken;
|
|
44
181
|
timeoutMs;
|
|
45
182
|
doFetch;
|
|
183
|
+
retry;
|
|
184
|
+
userAgent;
|
|
185
|
+
onResponse;
|
|
186
|
+
sleep;
|
|
46
187
|
constructor(options) {
|
|
188
|
+
this.options = options;
|
|
47
189
|
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
48
190
|
this.apiKey = options.apiKey;
|
|
49
191
|
this.identityToken = options.identityToken;
|
|
50
192
|
this.timeoutMs = options.timeoutMs ?? 30000;
|
|
51
|
-
this.doFetch = options.fetch ?? globalThis.fetch;
|
|
193
|
+
this.doFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
194
|
+
this.retry = { ...DEFAULT_RETRY, ...options.retry };
|
|
195
|
+
this.userAgent = [options.userAgent, `ycadence-sdk/${SDK_VERSION}`].filter((part) => part !== undefined && part !== "").join(" ");
|
|
196
|
+
this.onResponse = options.onResponse;
|
|
197
|
+
this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
198
|
+
}
|
|
199
|
+
withOptions(overrides) {
|
|
200
|
+
return new YCadenceClient({ ...this.options, ...overrides });
|
|
201
|
+
}
|
|
202
|
+
get fetchImplementation() {
|
|
203
|
+
return this.doFetch;
|
|
52
204
|
}
|
|
53
205
|
async health() {
|
|
54
|
-
return this.
|
|
55
|
-
}
|
|
56
|
-
async
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
206
|
+
return this.call("health");
|
|
207
|
+
}
|
|
208
|
+
async me(options) {
|
|
209
|
+
return this.call("me", {}, undefined, undefined, options);
|
|
210
|
+
}
|
|
211
|
+
async listProjects(query = {}, options) {
|
|
212
|
+
return this.call("listProjects", {}, query, undefined, options);
|
|
213
|
+
}
|
|
214
|
+
async listWorkItems(projectId, query = {}, options) {
|
|
215
|
+
return this.call("listWorkItems", { projectId }, {
|
|
216
|
+
filter: query.filter === undefined ? undefined : JSON.stringify(query.filter),
|
|
217
|
+
limit: query.limit,
|
|
218
|
+
cursor: query.cursor,
|
|
219
|
+
state: query.state,
|
|
220
|
+
priority: query.priority
|
|
221
|
+
}, undefined, options);
|
|
222
|
+
}
|
|
223
|
+
iterateWorkItems(projectId, options = {}) {
|
|
224
|
+
return paginate((cursor) => this.listWorkItems(projectId, {
|
|
225
|
+
filter: options.filter,
|
|
226
|
+
limit: options.pageSize ?? 100,
|
|
227
|
+
cursor
|
|
228
|
+
}));
|
|
229
|
+
}
|
|
230
|
+
async createWorkItem(projectId, body, options) {
|
|
231
|
+
return this.call("createWorkItem", { projectId }, undefined, body, options);
|
|
232
|
+
}
|
|
233
|
+
async getWorkItem(workItemId, options) {
|
|
234
|
+
return this.call("getWorkItem", { workItemId }, undefined, undefined, options);
|
|
235
|
+
}
|
|
236
|
+
async updateWorkItem(workItemId, body, options) {
|
|
237
|
+
return this.call("updateWorkItem", { workItemId }, undefined, body, options);
|
|
238
|
+
}
|
|
239
|
+
async deleteWorkItem(workItemId, options) {
|
|
240
|
+
await this.call("deleteWorkItem", { workItemId }, undefined, undefined, options);
|
|
241
|
+
}
|
|
242
|
+
async listStates(projectId, options) {
|
|
243
|
+
return this.call("listStates", { projectId }, undefined, undefined, options);
|
|
244
|
+
}
|
|
245
|
+
async listLabels(projectId, options) {
|
|
246
|
+
return this.call("listLabels", { projectId }, undefined, undefined, options);
|
|
247
|
+
}
|
|
248
|
+
async createLabel(projectId, body, options) {
|
|
249
|
+
return this.call("createLabel", { projectId }, undefined, body, options);
|
|
250
|
+
}
|
|
251
|
+
async listTypes(projectId, options) {
|
|
252
|
+
return this.call("listTypes", { projectId }, undefined, undefined, options);
|
|
253
|
+
}
|
|
254
|
+
async listCycles(projectId, options) {
|
|
255
|
+
return this.call("listCycles", { projectId }, undefined, undefined, options);
|
|
78
256
|
}
|
|
79
|
-
async
|
|
80
|
-
return this.
|
|
257
|
+
async createCycle(projectId, body, options) {
|
|
258
|
+
return this.call("createCycle", { projectId }, undefined, body, options);
|
|
81
259
|
}
|
|
82
|
-
async
|
|
83
|
-
return this.
|
|
260
|
+
async getCycleProgress(cycleId, options) {
|
|
261
|
+
return this.call("getCycleProgress", { cycleId }, undefined, undefined, options);
|
|
84
262
|
}
|
|
85
|
-
async
|
|
86
|
-
return this.
|
|
263
|
+
async addWorkItemsToCycle(cycleId, workItemIds, options) {
|
|
264
|
+
return this.call("addWorkItemsToCycle", { cycleId }, undefined, { workItemIds }, options);
|
|
87
265
|
}
|
|
88
|
-
async
|
|
89
|
-
|
|
266
|
+
async listViews(projectId, options) {
|
|
267
|
+
return this.call("listViews", { projectId }, undefined, undefined, options);
|
|
90
268
|
}
|
|
91
|
-
async
|
|
92
|
-
return this.
|
|
269
|
+
async createView(projectId, body, options) {
|
|
270
|
+
return this.call("createView", { projectId }, undefined, body, options);
|
|
93
271
|
}
|
|
94
|
-
async
|
|
95
|
-
return this.
|
|
272
|
+
async listIntake(projectId, query = {}, options) {
|
|
273
|
+
return this.call("listIntake", { projectId }, query, undefined, options);
|
|
96
274
|
}
|
|
97
|
-
async
|
|
98
|
-
return this.
|
|
275
|
+
async submitIntake(projectId, body, options) {
|
|
276
|
+
return this.call("submitIntake", { projectId }, undefined, body, options);
|
|
99
277
|
}
|
|
100
|
-
async
|
|
101
|
-
return this.
|
|
278
|
+
async triageIntake(intakeItemId, body, options) {
|
|
279
|
+
return this.call("triageIntake", { intakeItemId }, undefined, body, options);
|
|
102
280
|
}
|
|
103
|
-
async
|
|
104
|
-
return this.
|
|
281
|
+
async previewImport(projectId, body, options) {
|
|
282
|
+
return this.call("previewImport", { projectId }, undefined, body, options);
|
|
105
283
|
}
|
|
106
|
-
async
|
|
107
|
-
return this.
|
|
284
|
+
async startImport(projectId, body, options) {
|
|
285
|
+
return this.call("startImport", { projectId }, undefined, body, options);
|
|
108
286
|
}
|
|
109
|
-
async
|
|
110
|
-
return this.
|
|
287
|
+
async getImport(runId, options) {
|
|
288
|
+
return this.call("getImport", { runId }, undefined, undefined, options);
|
|
111
289
|
}
|
|
112
|
-
async
|
|
113
|
-
return this.
|
|
290
|
+
async listImports(projectId, options) {
|
|
291
|
+
return this.call("listImports", { projectId }, undefined, undefined, options);
|
|
114
292
|
}
|
|
115
|
-
async
|
|
293
|
+
async call(operation, params = {}, query, body, options = {}) {
|
|
294
|
+
const { method, path } = OPERATIONS[operation];
|
|
295
|
+
let url = buildPath(path, params);
|
|
296
|
+
if (query !== undefined) {
|
|
297
|
+
const search = new URLSearchParams;
|
|
298
|
+
for (const [name, value] of Object.entries(query)) {
|
|
299
|
+
if (value !== undefined)
|
|
300
|
+
search.set(name, String(value));
|
|
301
|
+
}
|
|
302
|
+
const encoded = search.toString();
|
|
303
|
+
if (encoded.length > 0)
|
|
304
|
+
url += `?${encoded}`;
|
|
305
|
+
}
|
|
306
|
+
return this.request(method, url, body, options);
|
|
307
|
+
}
|
|
308
|
+
async request(method, path, body, options) {
|
|
309
|
+
const retryable = isIdempotentMethod(method) || options.idempotencyKey !== undefined;
|
|
310
|
+
const retries = retryable ? options.retries ?? this.retry.retries : 0;
|
|
311
|
+
for (let attempt = 0;; attempt += 1) {
|
|
312
|
+
try {
|
|
313
|
+
return await this.send(method, path, body, options, attempt);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
const failure = error;
|
|
316
|
+
const again = attempt < retries && typeof failure.retryable === "boolean" && (failure.retryable || failure.code === "request_in_progress");
|
|
317
|
+
if (!again)
|
|
318
|
+
throw error;
|
|
319
|
+
await this.sleep(this.backoff(attempt, failure.retryAfter));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
backoff(attempt, retryAfter) {
|
|
324
|
+
if (retryAfter !== undefined)
|
|
325
|
+
return Math.min(retryAfter * 1000, this.retry.maxDelayMs);
|
|
326
|
+
const exponential = this.retry.baseDelayMs * 2 ** attempt;
|
|
327
|
+
const jittered = exponential / 2 + Math.random() * (exponential / 2);
|
|
328
|
+
return Math.min(jittered, this.retry.maxDelayMs);
|
|
329
|
+
}
|
|
330
|
+
async send(method, path, body, options, attempt) {
|
|
116
331
|
const controller = new AbortController;
|
|
117
332
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
333
|
+
const abort = () => controller.abort();
|
|
334
|
+
options.signal?.addEventListener("abort", abort);
|
|
335
|
+
const started = Date.now();
|
|
118
336
|
let response;
|
|
119
337
|
try {
|
|
120
338
|
const identityToken = typeof this.identityToken === "function" ? await this.identityToken() : this.identityToken;
|
|
339
|
+
const headers = {
|
|
340
|
+
"x-api-key": this.apiKey,
|
|
341
|
+
...identityToken === undefined || identityToken.length === 0 ? {} : { Authorization: `Bearer ${identityToken}` },
|
|
342
|
+
...body === undefined ? {} : { "Content-Type": "application/json" },
|
|
343
|
+
Accept: "application/json",
|
|
344
|
+
"User-Agent": this.userAgent
|
|
345
|
+
};
|
|
346
|
+
if (options.idempotencyKey !== undefined && !isIdempotentMethod(method)) {
|
|
347
|
+
headers["Idempotency-Key"] = options.idempotencyKey;
|
|
348
|
+
}
|
|
349
|
+
if (options.expectedVersion !== undefined && !isIdempotentMethod(method)) {
|
|
350
|
+
headers["If-Match"] = `"${String(options.expectedVersion).replace(/"/g, "")}"`;
|
|
351
|
+
}
|
|
121
352
|
response = await this.doFetch(`${this.baseUrl}${path}`, {
|
|
122
353
|
method,
|
|
123
|
-
headers
|
|
124
|
-
"x-api-key": this.apiKey,
|
|
125
|
-
...identityToken === undefined || identityToken.length === 0 ? {} : { Authorization: `Bearer ${identityToken}` },
|
|
126
|
-
...body === undefined ? {} : { "Content-Type": "application/json" }
|
|
127
|
-
},
|
|
354
|
+
headers,
|
|
128
355
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
129
356
|
signal: controller.signal
|
|
130
357
|
});
|
|
131
358
|
} catch (error) {
|
|
359
|
+
if (error instanceof RequestIntercepted)
|
|
360
|
+
throw error;
|
|
132
361
|
if (error instanceof Error && error.name === "AbortError") {
|
|
133
|
-
throw
|
|
362
|
+
throw createError(408, "timeout", `${method} ${path} timed out after ${this.timeoutMs}ms.`, undefined, { method, path });
|
|
134
363
|
}
|
|
135
|
-
throw
|
|
364
|
+
throw createError(0, "network_error", `Could not reach ${this.baseUrl}: ${error instanceof Error ? error.message : "unknown error"}`, undefined, { method, path });
|
|
136
365
|
} finally {
|
|
137
366
|
clearTimeout(timer);
|
|
367
|
+
options.signal?.removeEventListener("abort", abort);
|
|
138
368
|
}
|
|
369
|
+
const headerRequestId = response.headers?.get?.("x-request-id") ?? undefined;
|
|
370
|
+
this.onResponse?.({
|
|
371
|
+
method,
|
|
372
|
+
path,
|
|
373
|
+
status: response.status,
|
|
374
|
+
requestId: headerRequestId,
|
|
375
|
+
durationMs: Date.now() - started,
|
|
376
|
+
attempt,
|
|
377
|
+
replayed: response.headers?.get?.("idempotent-replayed") === "true"
|
|
378
|
+
});
|
|
139
379
|
if (response.status === 204)
|
|
140
380
|
return;
|
|
141
381
|
const text = await response.text();
|
|
142
382
|
const parsed = text.length === 0 ? undefined : safeJson(text);
|
|
143
383
|
if (!response.ok) {
|
|
144
|
-
const failure = parsed
|
|
145
|
-
throw
|
|
384
|
+
const failure = isRecord(parsed) ? parsed : {};
|
|
385
|
+
throw createError(response.status, typeof failure.code === "string" ? failure.code : codeForStatus(response.status), typeof failure.error === "string" ? failure.error : `${method} ${path} failed with ${response.status}.`, failure.issues, {
|
|
386
|
+
requestId: failure.requestId ?? headerRequestId,
|
|
387
|
+
details: failure.details,
|
|
388
|
+
retryAfter: parseRetryAfter(response.headers?.get?.("retry-after") ?? null),
|
|
389
|
+
method,
|
|
390
|
+
path
|
|
391
|
+
});
|
|
146
392
|
}
|
|
147
393
|
return parsed;
|
|
148
394
|
}
|
|
@@ -154,316 +400,3385 @@ function safeJson(text) {
|
|
|
154
400
|
return;
|
|
155
401
|
}
|
|
156
402
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
403
|
+
function isRecord(value) {
|
|
404
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
405
|
+
}
|
|
406
|
+
function codeForStatus(status) {
|
|
407
|
+
const codes = {
|
|
408
|
+
400: "bad_request",
|
|
409
|
+
401: "unauthorized",
|
|
410
|
+
403: "forbidden",
|
|
411
|
+
404: "not_found",
|
|
412
|
+
409: "conflict",
|
|
413
|
+
412: "precondition_failed",
|
|
414
|
+
422: "validation_failed",
|
|
415
|
+
429: "rate_limited"
|
|
416
|
+
};
|
|
417
|
+
return codes[status] ?? (status >= 500 ? "server_error" : "unknown");
|
|
418
|
+
}
|
|
419
|
+
function parseRetryAfter(value, now = Date.now()) {
|
|
420
|
+
if (value === null || value.trim() === "")
|
|
421
|
+
return;
|
|
422
|
+
const seconds = Number(value);
|
|
423
|
+
if (Number.isFinite(seconds))
|
|
424
|
+
return Math.max(0, seconds);
|
|
425
|
+
const date = Date.parse(value);
|
|
426
|
+
if (Number.isNaN(date))
|
|
427
|
+
return;
|
|
428
|
+
return Math.max(0, Math.ceil((date - now) / 1000));
|
|
429
|
+
}
|
|
430
|
+
// ../sdk/src/types.ts
|
|
431
|
+
var PRIORITIES = ["urgent", "high", "medium", "low", "none"];
|
|
432
|
+
var WORK_ITEM_FIELDS = {
|
|
433
|
+
id: true,
|
|
434
|
+
key: true,
|
|
435
|
+
sequenceId: true,
|
|
436
|
+
name: true,
|
|
437
|
+
priority: true,
|
|
438
|
+
stateId: true,
|
|
439
|
+
assigneeIds: true,
|
|
440
|
+
labelIds: true,
|
|
441
|
+
parentId: true,
|
|
442
|
+
sortOrder: true,
|
|
443
|
+
createdAt: true,
|
|
444
|
+
projectId: true,
|
|
445
|
+
version: true
|
|
446
|
+
};
|
|
447
|
+
var WORK_ITEM_DETAIL_FIELDS = {
|
|
448
|
+
...WORK_ITEM_FIELDS,
|
|
449
|
+
description: true,
|
|
450
|
+
startDate: true,
|
|
451
|
+
targetDate: true,
|
|
452
|
+
archivedAt: true,
|
|
453
|
+
updatedAt: true
|
|
454
|
+
};
|
|
455
|
+
// src/config.ts
|
|
456
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
457
|
+
import { join } from "node:path";
|
|
458
|
+
|
|
459
|
+
// src/exit.ts
|
|
460
|
+
var EXIT = {
|
|
461
|
+
ok: 0,
|
|
462
|
+
failure: 1,
|
|
463
|
+
usage: 2,
|
|
464
|
+
notFound: 3,
|
|
465
|
+
permission: 4,
|
|
466
|
+
conflict: 5,
|
|
467
|
+
validation: 6,
|
|
468
|
+
pending: 7,
|
|
469
|
+
tempfail: 75,
|
|
470
|
+
config: 78
|
|
471
|
+
};
|
|
472
|
+
var EXIT_CODE_TABLE = [
|
|
473
|
+
[EXIT.ok, "Success, confirmed by the server"],
|
|
474
|
+
[EXIT.failure, "Generic failure"],
|
|
475
|
+
[EXIT.usage, "Usage error: unknown command, bad flag, missing argument"],
|
|
476
|
+
[EXIT.notFound, "Not found (404)"],
|
|
477
|
+
[EXIT.permission, "Permission denied (401, 403)"],
|
|
478
|
+
[EXIT.conflict, "Conflict or failed precondition (409, 412)"],
|
|
479
|
+
[EXIT.validation, "Validation failed (400, 422)"],
|
|
480
|
+
[EXIT.pending, "Pending: approval or confirmation required (202)"],
|
|
481
|
+
[EXIT.tempfail, "Temporary failure; a retry may succeed (429, 5xx, network, timeout)"],
|
|
482
|
+
[EXIT.config, "Configuration error: no URL or key, or sign-in needed"]
|
|
483
|
+
];
|
|
484
|
+
|
|
485
|
+
class UsageError extends Error {
|
|
486
|
+
name = "UsageError";
|
|
487
|
+
code = "usage";
|
|
488
|
+
exitCode = EXIT.usage;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
class ConfigError extends Error {
|
|
492
|
+
name = "ConfigError";
|
|
493
|
+
code = "config";
|
|
494
|
+
exitCode = EXIT.config;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
class CliError extends Error {
|
|
498
|
+
code;
|
|
499
|
+
exitCode;
|
|
500
|
+
name = "CliError";
|
|
501
|
+
constructor(message, code, exitCode = EXIT.failure) {
|
|
502
|
+
super(message);
|
|
503
|
+
this.code = code;
|
|
504
|
+
this.exitCode = exitCode;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
function exitCodeForStatus(status) {
|
|
508
|
+
if (status === 0 || status === 408 || status === 429 || status >= 500)
|
|
509
|
+
return EXIT.tempfail;
|
|
510
|
+
if (status === 404)
|
|
511
|
+
return EXIT.notFound;
|
|
512
|
+
if (status === 401 || status === 403)
|
|
513
|
+
return EXIT.permission;
|
|
514
|
+
if (status === 409 || status === 412)
|
|
515
|
+
return EXIT.conflict;
|
|
516
|
+
if (status === 400 || status === 422)
|
|
517
|
+
return EXIT.validation;
|
|
518
|
+
if (status === 202)
|
|
519
|
+
return EXIT.pending;
|
|
520
|
+
return EXIT.failure;
|
|
521
|
+
}
|
|
522
|
+
function exitCodeFor(error) {
|
|
523
|
+
if (error instanceof YCadenceError)
|
|
524
|
+
return exitCodeForStatus(error.status);
|
|
525
|
+
if (error instanceof UsageError || error instanceof ConfigError || error instanceof CliError) {
|
|
526
|
+
return error.exitCode;
|
|
527
|
+
}
|
|
528
|
+
return EXIT.failure;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// src/config.ts
|
|
532
|
+
var PROFILE_KEYS = [
|
|
533
|
+
"url",
|
|
534
|
+
"org",
|
|
535
|
+
"project",
|
|
536
|
+
"format",
|
|
537
|
+
"oauthClientId",
|
|
538
|
+
"iapAudience"
|
|
539
|
+
];
|
|
540
|
+
var PROFILE_KEY_HELP = {
|
|
541
|
+
url: "The instance, e.g. https://ycadence.example.com",
|
|
542
|
+
org: "Organization slug",
|
|
543
|
+
project: "Default project id (or identifier, once the server lists projects)",
|
|
544
|
+
format: "Default output format: table, json or ndjson",
|
|
545
|
+
oauthClientId: "Desktop OAuth client id used by `ycadence login` for IAP",
|
|
546
|
+
iapAudience: "The IAP OAuth client id; the audience of the identity token"
|
|
547
|
+
};
|
|
548
|
+
function configDirectory(env) {
|
|
549
|
+
if (env.YCADENCE_CONFIG_DIR)
|
|
550
|
+
return env.YCADENCE_CONFIG_DIR;
|
|
551
|
+
if (env.XDG_CONFIG_HOME)
|
|
552
|
+
return join(env.XDG_CONFIG_HOME, "ycadence");
|
|
553
|
+
const home = env.HOME ?? env.USERPROFILE;
|
|
554
|
+
return home ? join(home, ".config", "ycadence") : undefined;
|
|
555
|
+
}
|
|
556
|
+
function fileConfigStore(directory) {
|
|
557
|
+
const path = join(directory, "config.json");
|
|
558
|
+
return {
|
|
559
|
+
path,
|
|
560
|
+
directory,
|
|
561
|
+
async load() {
|
|
562
|
+
let text;
|
|
563
|
+
try {
|
|
564
|
+
text = await readFile(path, "utf8");
|
|
565
|
+
} catch (error) {
|
|
566
|
+
if (error.code === "ENOENT")
|
|
567
|
+
return { profiles: {} };
|
|
568
|
+
throw error;
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
const parsed = JSON.parse(text);
|
|
572
|
+
return { currentProfile: parsed.currentProfile, profiles: parsed.profiles ?? {} };
|
|
573
|
+
} catch {
|
|
574
|
+
throw new ConfigError(`${path} is not valid JSON. Fix or delete it.`);
|
|
575
|
+
}
|
|
576
|
+
},
|
|
577
|
+
async save(config) {
|
|
578
|
+
await mkdir(directory, { recursive: true, mode: 448 });
|
|
579
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
580
|
+
await writeFile(temporary, `${JSON.stringify(config, null, 2)}
|
|
581
|
+
`, { mode: 384 });
|
|
582
|
+
await chmod(temporary, 384);
|
|
583
|
+
await rename(temporary, path);
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
function memoryConfigStore(initial = { profiles: {} }) {
|
|
588
|
+
let current = structuredClone(initial);
|
|
589
|
+
return {
|
|
590
|
+
path: undefined,
|
|
591
|
+
directory: undefined,
|
|
592
|
+
async load() {
|
|
593
|
+
return structuredClone(current);
|
|
594
|
+
},
|
|
595
|
+
async save(config) {
|
|
596
|
+
current = structuredClone(config);
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
function assertProfileKey(key) {
|
|
601
|
+
if (key === "apiKey" || key === "api-key" || key === "token") {
|
|
602
|
+
throw new UsageError("Keys are not stored in the config file. Use `ycadence login --api-key-stdin`, which keeps it in the OS keychain.");
|
|
603
|
+
}
|
|
604
|
+
if (!PROFILE_KEYS.includes(key)) {
|
|
605
|
+
throw new UsageError(`Unknown setting "${key}". One of: ${PROFILE_KEYS.join(", ")}.`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
function assertProfileName(name) {
|
|
609
|
+
if (!/^[A-Za-z0-9_.-]{1,64}$/.test(name)) {
|
|
610
|
+
throw new UsageError(`Profile names are letters, digits, ".", "_" and "-"; got "${name}".`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
function envSource(name, env) {
|
|
614
|
+
const current = env[`YCADENCE_${name}`];
|
|
615
|
+
return current !== undefined && current !== "" ? "env" : "legacy-env";
|
|
616
|
+
}
|
|
617
|
+
function resolveConfig(file, env, flags) {
|
|
618
|
+
const profileFromEnv = readEnv("PROFILE", env);
|
|
619
|
+
const profile = flags.profile ?? profileFromEnv ?? file.currentProfile ?? "default";
|
|
620
|
+
const stored = file.profiles[profile] ?? {};
|
|
621
|
+
const sources = {
|
|
622
|
+
profile: flags.profile !== undefined ? "flag" : profileFromEnv !== undefined ? envSource("PROFILE", env) : file.currentProfile !== undefined ? "profile" : "default"
|
|
623
|
+
};
|
|
624
|
+
const pick = (key, envName, flag) => {
|
|
625
|
+
if (flag !== undefined) {
|
|
626
|
+
sources[key] = "flag";
|
|
627
|
+
return flag;
|
|
628
|
+
}
|
|
629
|
+
const fromEnv = readEnv(envName, env);
|
|
630
|
+
if (fromEnv !== undefined) {
|
|
631
|
+
sources[key] = envSource(envName, env);
|
|
632
|
+
return fromEnv;
|
|
633
|
+
}
|
|
634
|
+
const fromProfile = stored[key];
|
|
635
|
+
sources[key] = fromProfile === undefined ? "none" : "profile";
|
|
636
|
+
return fromProfile;
|
|
637
|
+
};
|
|
638
|
+
return {
|
|
639
|
+
profile,
|
|
640
|
+
url: pick("url", "URL"),
|
|
641
|
+
org: pick("org", "ORG", flags.org),
|
|
642
|
+
project: pick("project", "PROJECT", flags.project),
|
|
643
|
+
format: pick("format", "FORMAT"),
|
|
644
|
+
oauthClientId: readEnv("OAUTH_CLIENT_ID", env) ?? stored.oauthClientId,
|
|
645
|
+
iapAudience: readEnv("IAP_AUDIENCE", env) ?? stored.iapAudience,
|
|
646
|
+
sources,
|
|
647
|
+
stored
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// src/credentials.ts
|
|
652
|
+
import { execFile, spawn } from "node:child_process";
|
|
653
|
+
import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, rename as rename2, writeFile as writeFile2 } from "node:fs/promises";
|
|
654
|
+
import { join as join2 } from "node:path";
|
|
655
|
+
var SERVICE = "ycadence";
|
|
656
|
+
var secretAccount = {
|
|
657
|
+
apiKey: (profile) => `${profile}:api_key`,
|
|
658
|
+
refreshToken: (profile) => `${profile}:refresh_token`,
|
|
659
|
+
identityToken: (profile) => `${profile}:identity_token`,
|
|
660
|
+
oauthClientSecret: (profile) => `${profile}:oauth_client_secret`
|
|
661
|
+
};
|
|
662
|
+
var nodeExec = (command, args, stdin) => new Promise((resolve) => {
|
|
663
|
+
if (stdin === undefined) {
|
|
664
|
+
execFile(command, args, { encoding: "utf8" }, (error, stdout2, stderr2) => {
|
|
665
|
+
const code = error === null ? 0 : typeof error.code === "number" ? error.code : error ? 127 : 0;
|
|
666
|
+
resolve({ code, stdout: stdout2, stderr: stderr2 });
|
|
667
|
+
});
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
671
|
+
let stdout = "";
|
|
672
|
+
let stderr = "";
|
|
673
|
+
child.stdout.on("data", (chunk) => {
|
|
674
|
+
stdout += chunk.toString("utf8");
|
|
675
|
+
});
|
|
676
|
+
child.stderr.on("data", (chunk) => {
|
|
677
|
+
stderr += chunk.toString("utf8");
|
|
678
|
+
});
|
|
679
|
+
child.on("error", () => resolve({ code: 127, stdout, stderr }));
|
|
680
|
+
child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
681
|
+
child.stdin.end(stdin);
|
|
682
|
+
});
|
|
683
|
+
function assertAccount(account) {
|
|
684
|
+
if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(account)) {
|
|
685
|
+
throw new Error(`Invalid credential account name "${account}".`);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
function hex(value) {
|
|
689
|
+
return Buffer.from(value, "utf8").toString("hex");
|
|
690
|
+
}
|
|
691
|
+
function macKeychainStore(exec = nodeExec) {
|
|
692
|
+
return {
|
|
693
|
+
kind: "keychain",
|
|
694
|
+
description: "macOS Keychain",
|
|
695
|
+
async get(account) {
|
|
696
|
+
assertAccount(account);
|
|
697
|
+
const result = await exec("security", [
|
|
698
|
+
"find-generic-password",
|
|
699
|
+
"-s",
|
|
700
|
+
SERVICE,
|
|
701
|
+
"-a",
|
|
702
|
+
account,
|
|
703
|
+
"-w"
|
|
704
|
+
]);
|
|
705
|
+
if (result.code !== 0)
|
|
706
|
+
return;
|
|
707
|
+
const value = result.stdout.replace(/\n$/, "");
|
|
708
|
+
return value === "" ? undefined : value;
|
|
709
|
+
},
|
|
710
|
+
async set(account, value) {
|
|
711
|
+
assertAccount(account);
|
|
712
|
+
const result = await exec("security", ["-i"], `add-generic-password -U -s ${SERVICE} -a ${account} -l ${SERVICE} -X ${hex(value)}
|
|
713
|
+
`);
|
|
714
|
+
if (result.code !== 0) {
|
|
715
|
+
throw new Error(`Could not save to the macOS Keychain (security exited ${result.code}).`);
|
|
716
|
+
}
|
|
717
|
+
},
|
|
718
|
+
async delete(account) {
|
|
719
|
+
assertAccount(account);
|
|
720
|
+
await exec("security", ["delete-generic-password", "-s", SERVICE, "-a", account]);
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
function libsecretStore(exec = nodeExec) {
|
|
725
|
+
return {
|
|
726
|
+
kind: "libsecret",
|
|
727
|
+
description: "Secret Service (libsecret)",
|
|
728
|
+
async get(account) {
|
|
729
|
+
assertAccount(account);
|
|
730
|
+
const result = await exec("secret-tool", ["lookup", "service", SERVICE, "account", account]);
|
|
731
|
+
if (result.code !== 0)
|
|
732
|
+
return;
|
|
733
|
+
const value = result.stdout.replace(/\n$/, "");
|
|
734
|
+
return value === "" ? undefined : value;
|
|
735
|
+
},
|
|
736
|
+
async set(account, value) {
|
|
737
|
+
assertAccount(account);
|
|
738
|
+
const result = await exec("secret-tool", ["store", `--label=${SERVICE} ${account}`, "service", SERVICE, "account", account], value);
|
|
739
|
+
if (result.code !== 0) {
|
|
740
|
+
throw new Error(`Could not save to the Secret Service (secret-tool exited ${result.code}).`);
|
|
741
|
+
}
|
|
742
|
+
},
|
|
743
|
+
async delete(account) {
|
|
744
|
+
assertAccount(account);
|
|
745
|
+
await exec("secret-tool", ["clear", "service", SERVICE, "account", account]);
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
function fileSecretStore(directory) {
|
|
750
|
+
const path = join2(directory, "credentials.json");
|
|
751
|
+
const load = async () => {
|
|
752
|
+
try {
|
|
753
|
+
return JSON.parse(await readFile2(path, "utf8"));
|
|
754
|
+
} catch {
|
|
755
|
+
return {};
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
const save = async (values) => {
|
|
759
|
+
await mkdir2(directory, { recursive: true, mode: 448 });
|
|
760
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
761
|
+
await writeFile2(temporary, `${JSON.stringify(values, null, 2)}
|
|
762
|
+
`, { mode: 384 });
|
|
763
|
+
await chmod2(temporary, 384);
|
|
764
|
+
await rename2(temporary, path);
|
|
765
|
+
};
|
|
766
|
+
return {
|
|
767
|
+
kind: "file",
|
|
768
|
+
description: `${path} (mode 0600; no OS keychain found)`,
|
|
769
|
+
async get(account) {
|
|
770
|
+
return (await load())[account];
|
|
771
|
+
},
|
|
772
|
+
async set(account, value) {
|
|
773
|
+
const values = await load();
|
|
774
|
+
values[account] = value;
|
|
775
|
+
await save(values);
|
|
776
|
+
},
|
|
777
|
+
async delete(account) {
|
|
778
|
+
const values = await load();
|
|
779
|
+
if (!(account in values))
|
|
780
|
+
return;
|
|
781
|
+
delete values[account];
|
|
782
|
+
await save(values);
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
function memorySecretStore(initial = {}) {
|
|
787
|
+
const values = new Map(Object.entries(initial));
|
|
788
|
+
return {
|
|
789
|
+
kind: "memory",
|
|
790
|
+
description: "in memory (nothing is saved)",
|
|
791
|
+
async get(account) {
|
|
792
|
+
return values.get(account);
|
|
793
|
+
},
|
|
794
|
+
async set(account, value) {
|
|
795
|
+
values.set(account, value);
|
|
796
|
+
},
|
|
797
|
+
async delete(account) {
|
|
798
|
+
values.delete(account);
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
async function detectSecretStore(options) {
|
|
803
|
+
const exec = options.exec ?? nodeExec;
|
|
804
|
+
const platform = options.platform ?? process.platform;
|
|
805
|
+
const fallback = () => options.directory === undefined ? memorySecretStore() : fileSecretStore(options.directory);
|
|
806
|
+
if (options.env.YCADENCE_CREDENTIAL_STORE === "file")
|
|
807
|
+
return fallback();
|
|
808
|
+
if (platform === "darwin") {
|
|
809
|
+
const probe = await exec("security", ["list-keychains"]);
|
|
810
|
+
if (probe.code === 0)
|
|
811
|
+
return macKeychainStore(exec);
|
|
812
|
+
}
|
|
813
|
+
if (platform === "linux" && options.env.DBUS_SESSION_BUS_ADDRESS) {
|
|
814
|
+
const probe = await exec("secret-tool", []);
|
|
815
|
+
if (probe.code !== 127)
|
|
816
|
+
return libsecretStore(exec);
|
|
817
|
+
}
|
|
818
|
+
return fallback();
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// src/parser.ts
|
|
822
|
+
var GLOBAL_FLAGS = [
|
|
823
|
+
{ name: "help", short: "h", type: "boolean", description: "Help for the command" },
|
|
824
|
+
{
|
|
825
|
+
name: "format",
|
|
826
|
+
type: "string",
|
|
827
|
+
values: ["table", "json", "ndjson"],
|
|
828
|
+
placeholder: "<format>",
|
|
829
|
+
description: "Output format: table (default), json or ndjson"
|
|
830
|
+
},
|
|
831
|
+
{ name: "json", type: "boolean", description: "Shorthand for --format json" },
|
|
832
|
+
{
|
|
833
|
+
name: "fields",
|
|
834
|
+
type: "string",
|
|
835
|
+
placeholder: "<a,b,c>",
|
|
836
|
+
description: "Columns for table output; the projection for json"
|
|
837
|
+
},
|
|
838
|
+
{
|
|
839
|
+
name: "quiet",
|
|
840
|
+
short: "q",
|
|
841
|
+
type: "boolean",
|
|
842
|
+
description: "Print only ids or keys, one per line"
|
|
843
|
+
},
|
|
844
|
+
{
|
|
845
|
+
name: "verbose",
|
|
846
|
+
short: "v",
|
|
847
|
+
type: "boolean",
|
|
848
|
+
description: "Request ids and timings on stderr; never credentials"
|
|
849
|
+
},
|
|
850
|
+
{ name: "profile", type: "string", placeholder: "<name>", description: "Use a stored profile" },
|
|
851
|
+
{ name: "org", type: "string", placeholder: "<slug>", description: "Organization slug" },
|
|
852
|
+
{
|
|
853
|
+
name: "project",
|
|
854
|
+
short: "p",
|
|
855
|
+
type: "string",
|
|
856
|
+
placeholder: "<id|identifier>",
|
|
857
|
+
description: "Project for commands that need one"
|
|
858
|
+
},
|
|
859
|
+
{ name: "limit", type: "number", placeholder: "<n>", description: "Page size for lists" },
|
|
860
|
+
{ name: "cursor", type: "string", placeholder: "<cursor>", description: "Continue a list" },
|
|
861
|
+
{ name: "all", type: "boolean", description: "Follow the cursor to the end of a list" },
|
|
862
|
+
{
|
|
863
|
+
name: "dry-run",
|
|
864
|
+
type: "boolean",
|
|
865
|
+
description: "Show the write a command would send, and send nothing"
|
|
866
|
+
},
|
|
867
|
+
{
|
|
868
|
+
name: "yes",
|
|
869
|
+
short: "y",
|
|
870
|
+
type: "boolean",
|
|
871
|
+
description: "Skip confirmation (required outside a terminal for destructive commands)"
|
|
872
|
+
},
|
|
873
|
+
{
|
|
874
|
+
name: "idempotency-key",
|
|
875
|
+
type: "string",
|
|
876
|
+
placeholder: "<key>",
|
|
877
|
+
description: "Sent as Idempotency-Key on a write, so retrying it is safe"
|
|
878
|
+
},
|
|
879
|
+
{
|
|
880
|
+
name: "retry",
|
|
881
|
+
type: "number",
|
|
882
|
+
placeholder: "<n>",
|
|
883
|
+
description: "Retry temporary failures n times (a write gets an idempotency key)"
|
|
884
|
+
}
|
|
885
|
+
];
|
|
886
|
+
var GLOBAL_BY_NAME = new Map(GLOBAL_FLAGS.map((flag) => [flag.name, flag]));
|
|
887
|
+
var GLOBAL_BY_SHORT = new Map(GLOBAL_FLAGS.filter((flag) => flag.short !== undefined).map((flag) => [flag.short, flag]));
|
|
888
|
+
function findChild(node, word) {
|
|
889
|
+
return node.children?.find((child) => child.name === word || (child.aliases?.includes(word) ?? false));
|
|
890
|
+
}
|
|
891
|
+
function globalTakesValue(token) {
|
|
892
|
+
if (token.includes("="))
|
|
893
|
+
return false;
|
|
894
|
+
const flag = token.startsWith("--") ? GLOBAL_BY_NAME.get(token.slice(2)) : GLOBAL_BY_SHORT.get(token.slice(1));
|
|
895
|
+
return flag !== undefined && flag.type !== "boolean";
|
|
896
|
+
}
|
|
897
|
+
function isGlobalToken(token) {
|
|
898
|
+
if (token.startsWith("--")) {
|
|
899
|
+
const name = token.slice(2).split("=")[0];
|
|
900
|
+
return GLOBAL_BY_NAME.has(name) || name.startsWith("no-") && GLOBAL_BY_NAME.has(name.slice(3));
|
|
901
|
+
}
|
|
902
|
+
return token.length === 2 && GLOBAL_BY_SHORT.has(token.slice(1));
|
|
903
|
+
}
|
|
904
|
+
function resolveCommand(root, argv) {
|
|
905
|
+
let node = root;
|
|
906
|
+
const path = [];
|
|
907
|
+
const consumed = new Set;
|
|
908
|
+
for (let index = 0;index < argv.length; index += 1) {
|
|
909
|
+
const token = argv[index];
|
|
910
|
+
if (token === "--")
|
|
911
|
+
break;
|
|
912
|
+
if (token.startsWith("-") && token !== "-") {
|
|
913
|
+
if (!isGlobalToken(token))
|
|
914
|
+
break;
|
|
915
|
+
if (globalTakesValue(token))
|
|
916
|
+
index += 1;
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
const child = findChild(node, token);
|
|
920
|
+
if (child === undefined)
|
|
921
|
+
break;
|
|
922
|
+
node = child;
|
|
923
|
+
path.push(child.name);
|
|
924
|
+
consumed.add(index);
|
|
925
|
+
}
|
|
926
|
+
return { path, command: node, consumed };
|
|
927
|
+
}
|
|
928
|
+
function closest(word, candidates) {
|
|
929
|
+
let best;
|
|
930
|
+
for (const candidate of candidates) {
|
|
931
|
+
const distance = levenshtein(word, candidate);
|
|
932
|
+
if (distance <= 2 && (best === undefined || distance < best.distance)) {
|
|
933
|
+
best = { name: candidate, distance };
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
return best?.name;
|
|
937
|
+
}
|
|
938
|
+
function levenshtein(a, b) {
|
|
939
|
+
const row = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
940
|
+
for (let i = 1;i <= a.length; i += 1) {
|
|
941
|
+
let previous = row[0];
|
|
942
|
+
row[0] = i;
|
|
943
|
+
for (let j = 1;j <= b.length; j += 1) {
|
|
944
|
+
const current = row[j];
|
|
945
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
946
|
+
previous = current;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
return row[b.length];
|
|
950
|
+
}
|
|
951
|
+
function convert(flag, raw, display) {
|
|
952
|
+
if (flag.type === "number") {
|
|
953
|
+
const value = Number(raw);
|
|
954
|
+
if (raw.trim() === "" || !Number.isFinite(value)) {
|
|
955
|
+
throw new UsageError(`${display} takes a number, got "${raw}".`);
|
|
956
|
+
}
|
|
957
|
+
return value;
|
|
958
|
+
}
|
|
959
|
+
if (flag.type === "boolean") {
|
|
960
|
+
if (raw === "true" || raw === "1" || raw === "")
|
|
961
|
+
return true;
|
|
962
|
+
if (raw === "false" || raw === "0")
|
|
963
|
+
return false;
|
|
964
|
+
throw new UsageError(`${display} is true or false, got "${raw}".`);
|
|
965
|
+
}
|
|
966
|
+
if (flag.values !== undefined && !flag.values.includes(raw)) {
|
|
967
|
+
throw new UsageError(`${display} is one of ${flag.values.join(", ")}; got "${raw}".`);
|
|
968
|
+
}
|
|
969
|
+
return raw;
|
|
970
|
+
}
|
|
971
|
+
function commandName(path) {
|
|
972
|
+
return ["ycadence", ...path].join(" ");
|
|
973
|
+
}
|
|
974
|
+
function parse(root, argv) {
|
|
975
|
+
const { path, command, consumed } = resolveCommand(root, argv);
|
|
976
|
+
const flags = [...GLOBAL_FLAGS, ...command.flags ?? []];
|
|
977
|
+
const byName = new Map(flags.map((flag) => [flag.name, flag]));
|
|
978
|
+
const byShort = new Map(flags.filter((flag) => flag.short !== undefined).map((flag) => [flag.short, flag]));
|
|
979
|
+
const values = {};
|
|
980
|
+
const positionals = [];
|
|
981
|
+
const rest = [];
|
|
982
|
+
const assign = (flag, value) => {
|
|
983
|
+
if (flag.multiple === true) {
|
|
984
|
+
const list = values[flag.name] ?? [];
|
|
985
|
+
values[flag.name] = [...list, value];
|
|
986
|
+
} else {
|
|
987
|
+
values[flag.name] = value;
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
for (let index = 0;index < argv.length; index += 1) {
|
|
991
|
+
if (consumed.has(index))
|
|
992
|
+
continue;
|
|
993
|
+
const token = argv[index];
|
|
994
|
+
if (token === "--") {
|
|
995
|
+
rest.push(...argv.slice(index + 1));
|
|
996
|
+
break;
|
|
997
|
+
}
|
|
998
|
+
if (token.startsWith("--")) {
|
|
999
|
+
const equals = token.indexOf("=");
|
|
1000
|
+
const name = equals === -1 ? token.slice(2) : token.slice(2, equals);
|
|
1001
|
+
let flag = byName.get(name);
|
|
1002
|
+
let negated = false;
|
|
1003
|
+
if (flag === undefined && name.startsWith("no-")) {
|
|
1004
|
+
const positive = byName.get(name.slice(3));
|
|
1005
|
+
if (positive?.type === "boolean") {
|
|
1006
|
+
flag = positive;
|
|
1007
|
+
negated = true;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
if (flag === undefined) {
|
|
1011
|
+
const suggestion = closest(name, flags.filter((candidate) => candidate.hidden !== true).map((candidate) => candidate.name));
|
|
1012
|
+
throw new UsageError(`Unknown flag --${name} for "${commandName(path)}".${suggestion === undefined ? "" : ` Did you mean --${suggestion}?`}`);
|
|
1013
|
+
}
|
|
1014
|
+
const display = `--${flag.name}`;
|
|
1015
|
+
if (negated) {
|
|
1016
|
+
values[flag.name] = false;
|
|
1017
|
+
continue;
|
|
1018
|
+
}
|
|
1019
|
+
if (equals !== -1) {
|
|
1020
|
+
assign(flag, convert(flag, token.slice(equals + 1), display));
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
if (flag.type === "boolean") {
|
|
1024
|
+
assign(flag, true);
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
const next = argv[index + 1];
|
|
1028
|
+
if (next === undefined || next.startsWith("--") && next.length > 2) {
|
|
1029
|
+
throw new UsageError(`${display} needs a value.`);
|
|
1030
|
+
}
|
|
1031
|
+
assign(flag, convert(flag, next, display));
|
|
1032
|
+
index += 1;
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
if (token.startsWith("-") && token.length > 1 && !/^-\d/.test(token)) {
|
|
1036
|
+
const short = token.slice(1);
|
|
1037
|
+
const flag = short.length === 1 ? byShort.get(short) : undefined;
|
|
1038
|
+
if (flag === undefined) {
|
|
1039
|
+
throw new UsageError(`Unknown flag ${token} for "${commandName(path)}".`);
|
|
1040
|
+
}
|
|
1041
|
+
if (flag.type === "boolean") {
|
|
1042
|
+
assign(flag, true);
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
const next = argv[index + 1];
|
|
1046
|
+
if (next === undefined)
|
|
1047
|
+
throw new UsageError(`${token} needs a value.`);
|
|
1048
|
+
assign(flag, convert(flag, next, `-${short}`));
|
|
1049
|
+
index += 1;
|
|
1050
|
+
continue;
|
|
1051
|
+
}
|
|
1052
|
+
positionals.push(token);
|
|
1053
|
+
}
|
|
1054
|
+
const help = values.help === true;
|
|
1055
|
+
const args = {};
|
|
1056
|
+
const specs = command.args ?? [];
|
|
1057
|
+
if (!help && command.run !== undefined) {
|
|
1058
|
+
let position = 0;
|
|
1059
|
+
for (const arg of specs) {
|
|
1060
|
+
if (arg.variadic === true) {
|
|
1061
|
+
args[arg.name] = positionals.slice(position);
|
|
1062
|
+
position = positionals.length;
|
|
1063
|
+
if (arg.required === true && args[arg.name].length === 0) {
|
|
1064
|
+
throw new UsageError(`Missing <${arg.name}>. Usage: ${usageLine(path, command)}`);
|
|
1065
|
+
}
|
|
1066
|
+
continue;
|
|
1067
|
+
}
|
|
1068
|
+
args[arg.name] = positionals[position];
|
|
1069
|
+
if (positionals[position] !== undefined)
|
|
1070
|
+
position += 1;
|
|
1071
|
+
else if (arg.required === true) {
|
|
1072
|
+
throw new UsageError(`Missing <${arg.name}>. Usage: ${usageLine(path, command)}`);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
if (position < positionals.length) {
|
|
1076
|
+
const extra = positionals[position];
|
|
1077
|
+
const hint = command.children === undefined ? "" : (() => {
|
|
1078
|
+
const suggestion = closest(extra, command.children.filter((child) => child.hidden !== true).map((child) => child.name));
|
|
1079
|
+
return suggestion === undefined ? "" : ` Did you mean "${suggestion}"?`;
|
|
1080
|
+
})();
|
|
1081
|
+
throw new UsageError(`Unexpected argument "${extra}" for "${commandName(path)}".${hint} Usage: ${usageLine(path, command)}`);
|
|
1082
|
+
}
|
|
1083
|
+
for (const flag of command.flags ?? []) {
|
|
1084
|
+
if (flag.required === true && values[flag.name] === undefined) {
|
|
1085
|
+
throw new UsageError(`--${flag.name} is required. Usage: ${usageLine(path, command)}`);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
} else if (!help && positionals.length > 0) {
|
|
1089
|
+
const word = positionals[0];
|
|
1090
|
+
const candidates = (command.children ?? []).filter((child) => child.hidden !== true).map((child) => child.name);
|
|
1091
|
+
const suggestion = closest(word, candidates);
|
|
1092
|
+
throw new UsageError(path.length === 0 ? `Unknown command: ${word}.${suggestion === undefined ? "" : ` Did you mean "${suggestion}"?`}` : `Unknown command "${word}" for "${commandName(path)}".${suggestion === undefined ? "" : ` Did you mean "${suggestion}"?`}`);
|
|
1093
|
+
}
|
|
1094
|
+
return { path, command, args, flags: values, rest, help };
|
|
1095
|
+
}
|
|
1096
|
+
function usageLine(path, command) {
|
|
1097
|
+
const parts = [commandName(path)];
|
|
1098
|
+
if (command.children !== undefined && command.run === undefined)
|
|
1099
|
+
parts.push("<command>");
|
|
1100
|
+
for (const arg of command.args ?? []) {
|
|
1101
|
+
const name = arg.variadic === true ? `${arg.name}...` : arg.name;
|
|
1102
|
+
parts.push(arg.required === true ? `<${name}>` : `[${name}]`);
|
|
1103
|
+
}
|
|
1104
|
+
for (const flag of command.flags ?? []) {
|
|
1105
|
+
if (flag.required === true)
|
|
1106
|
+
parts.push(`--${flag.name} ${flag.placeholder ?? "<value>"}`);
|
|
1107
|
+
}
|
|
1108
|
+
if ((command.flags ?? []).some((flag) => flag.required !== true && flag.hidden !== true)) {
|
|
1109
|
+
parts.push("[flags]");
|
|
1110
|
+
}
|
|
1111
|
+
return parts.join(" ");
|
|
1112
|
+
}
|
|
1113
|
+
function globalOptions(flags, defaults = {}) {
|
|
1114
|
+
const requested = flags.json === true ? "json" : flags.format ?? defaults.format;
|
|
1115
|
+
const format = requested === "json" || requested === "ndjson" ? requested : "table";
|
|
1116
|
+
const fields = typeof flags.fields === "string" ? flags.fields.split(",").map((field) => field.trim()).filter((field) => field !== "") : undefined;
|
|
1117
|
+
const limit = flags.limit;
|
|
1118
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200)) {
|
|
1119
|
+
throw new UsageError("--limit is a whole number from 1 to 200.");
|
|
1120
|
+
}
|
|
1121
|
+
const retry = flags.retry;
|
|
1122
|
+
if (retry !== undefined && (!Number.isInteger(retry) || retry < 0 || retry > 10)) {
|
|
1123
|
+
throw new UsageError("--retry is a whole number from 0 to 10.");
|
|
1124
|
+
}
|
|
1125
|
+
const key = flags["idempotency-key"];
|
|
1126
|
+
if (key !== undefined && !/^[\x21-\x7e]{1,255}$/.test(key)) {
|
|
1127
|
+
throw new UsageError("--idempotency-key is 1 to 255 printable ASCII characters.");
|
|
1128
|
+
}
|
|
1129
|
+
return {
|
|
1130
|
+
format,
|
|
1131
|
+
fields,
|
|
1132
|
+
quiet: flags.quiet === true,
|
|
1133
|
+
verbose: flags.verbose === true,
|
|
1134
|
+
dryRun: flags["dry-run"] === true,
|
|
1135
|
+
yes: flags.yes === true,
|
|
1136
|
+
limit,
|
|
1137
|
+
cursor: flags.cursor,
|
|
1138
|
+
all: flags.all === true,
|
|
1139
|
+
idempotencyKey: key,
|
|
1140
|
+
retry,
|
|
1141
|
+
profile: flags.profile,
|
|
1142
|
+
org: flags.org,
|
|
1143
|
+
project: flags.project
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// src/help.ts
|
|
1148
|
+
function visibleChildren(command) {
|
|
1149
|
+
return (command.children ?? []).filter((child) => child.hidden !== true);
|
|
1150
|
+
}
|
|
1151
|
+
function flagLabel(flag) {
|
|
1152
|
+
const names = [flag.short === undefined ? undefined : `-${flag.short}`, `--${flag.name}`].filter((name) => name !== undefined).join(", ");
|
|
1153
|
+
return flag.type === "boolean" ? names : `${names} ${flag.placeholder ?? "<value>"}`;
|
|
1154
|
+
}
|
|
1155
|
+
function flagLines(flags) {
|
|
1156
|
+
const visible = flags.filter((flag) => flag.hidden !== true);
|
|
1157
|
+
const labels = visible.map(flagLabel);
|
|
1158
|
+
const width = Math.max(0, ...labels.map((label) => label.length));
|
|
1159
|
+
return visible.map((flag, index) => {
|
|
1160
|
+
const notes = [
|
|
1161
|
+
flag.required === true ? "required" : undefined,
|
|
1162
|
+
flag.multiple === true ? "repeatable" : undefined
|
|
1163
|
+
].filter((note) => note !== undefined);
|
|
1164
|
+
return ` ${labels[index].padEnd(width)} ${flag.description}${notes.length === 0 ? "" : ` (${notes.join(", ")})`}`;
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
function commandLines(path, children) {
|
|
1168
|
+
const names = children.map((child) => [child.name, ...child.aliases ?? []].join(", "));
|
|
1169
|
+
const width = Math.max(0, ...names.map((name) => name.length));
|
|
1170
|
+
return children.map((child, index) => ` ${names[index].padEnd(width)} ${child.summary}`);
|
|
1171
|
+
}
|
|
1172
|
+
var CONFIGURATION_HELP = `Configuration:
|
|
1173
|
+
Profiles live in ~/.config/ycadence/config.json ($XDG_CONFIG_HOME aware);
|
|
1174
|
+
keys in the OS keychain. Environment variables override both:
|
|
1175
|
+
YCADENCE_URL the instance, e.g. https://ycadence.example.com
|
|
176
1176
|
YCADENCE_API_KEY a project, organization or personal key
|
|
177
1177
|
YCADENCE_IDENTITY_TOKEN
|
|
178
1178
|
optional: a Google identity token, for an instance behind
|
|
179
|
-
IAP
|
|
180
|
-
|
|
1179
|
+
IAP (or use \`ycadence login\`)
|
|
1180
|
+
YCADENCE_PROFILE, YCADENCE_PROJECT, YCADENCE_ORG, YCADENCE_FORMAT
|
|
181
1181
|
|
|
182
1182
|
The DEVPULSE_* names are still read when the YCADENCE_* one is unset.
|
|
183
|
-
\`devpulse\` is kept as an alias of this command
|
|
184
|
-
|
|
185
|
-
|
|
1183
|
+
\`devpulse\` is kept as an alias of this command. For a short name, add
|
|
1184
|
+
\`alias yc=ycadence\` to your shell.`;
|
|
1185
|
+
function renderHelp(root, path, command) {
|
|
1186
|
+
const isRoot = path.length === 0;
|
|
1187
|
+
const lines = [];
|
|
1188
|
+
lines.push(isRoot ? "ycadence — project and work management from the command line" : `${commandName(path)} — ${command.summary}`);
|
|
1189
|
+
if (command.deprecatedFor !== undefined) {
|
|
1190
|
+
lines.push("", `Deprecated: use \`${command.deprecatedFor}\`.`);
|
|
1191
|
+
}
|
|
1192
|
+
if (command.description !== undefined)
|
|
1193
|
+
lines.push("", command.description);
|
|
1194
|
+
lines.push("", "Usage:", ` ${isRoot ? "ycadence <command> [flags]" : usageLine(path, command)}`);
|
|
1195
|
+
const children = visibleChildren(command);
|
|
1196
|
+
if (children.length > 0) {
|
|
1197
|
+
lines.push("", "Commands:", ...commandLines(path, children));
|
|
1198
|
+
}
|
|
1199
|
+
if ((command.args ?? []).length > 0) {
|
|
1200
|
+
const width = Math.max(...(command.args ?? []).map((arg) => arg.name.length + 2));
|
|
1201
|
+
lines.push("", "Arguments:", ...(command.args ?? []).map((arg) => ` ${`<${arg.name}>`.padEnd(width)} ${arg.description}`));
|
|
1202
|
+
}
|
|
1203
|
+
if ((command.flags ?? []).some((flag) => flag.hidden !== true)) {
|
|
1204
|
+
lines.push("", "Flags:", ...flagLines(command.flags ?? []));
|
|
1205
|
+
}
|
|
1206
|
+
if ((command.examples ?? []).length > 0) {
|
|
1207
|
+
lines.push("", "Examples:", ...(command.examples ?? []).map((example) => ` ${example}`));
|
|
1208
|
+
}
|
|
1209
|
+
if (isRoot) {
|
|
1210
|
+
lines.push("", "Global flags:", ...flagLines(GLOBAL_FLAGS));
|
|
1211
|
+
lines.push("", CONFIGURATION_HELP);
|
|
1212
|
+
lines.push("", "Exit codes:", ...EXIT_CODE_TABLE.map(([code, meaning]) => ` ${String(code).padStart(2)} ${meaning}`));
|
|
1213
|
+
lines.push("", "Run `ycadence <command> --help` for a command, or `ycadence help <command>`.");
|
|
1214
|
+
} else {
|
|
1215
|
+
lines.push("", "Global flags: see `ycadence --help`.");
|
|
1216
|
+
}
|
|
1217
|
+
return lines.join(`
|
|
1218
|
+
`);
|
|
1219
|
+
}
|
|
1220
|
+
function walk(command, path = [], includeHidden = false) {
|
|
1221
|
+
const entries = [];
|
|
1222
|
+
for (const child of command.children ?? []) {
|
|
1223
|
+
if (child.hidden === true && !includeHidden)
|
|
1224
|
+
continue;
|
|
1225
|
+
const childPath = [...path, child.name];
|
|
1226
|
+
entries.push({ path: childPath, command: child });
|
|
1227
|
+
entries.push(...walk(child, childPath, includeHidden));
|
|
1228
|
+
}
|
|
1229
|
+
return entries;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// src/oauth.ts
|
|
1233
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
1234
|
+
import { createServer } from "node:http";
|
|
1235
|
+
var GOOGLE_ENDPOINTS = {
|
|
1236
|
+
authorization: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
1237
|
+
token: "https://oauth2.googleapis.com/token",
|
|
1238
|
+
device: "https://oauth2.googleapis.com/device/code"
|
|
1239
|
+
};
|
|
1240
|
+
function endpointsFrom(env) {
|
|
1241
|
+
return {
|
|
1242
|
+
authorization: env.YCADENCE_OAUTH_AUTH_URL || GOOGLE_ENDPOINTS.authorization,
|
|
1243
|
+
token: env.YCADENCE_OAUTH_TOKEN_URL || GOOGLE_ENDPOINTS.token,
|
|
1244
|
+
device: env.YCADENCE_OAUTH_DEVICE_URL || GOOGLE_ENDPOINTS.device
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
var SCOPE = "openid email";
|
|
1248
|
+
function base64url(buffer) {
|
|
1249
|
+
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1250
|
+
}
|
|
1251
|
+
function createPkce(random = randomBytes) {
|
|
1252
|
+
const verifier = base64url(random(32));
|
|
1253
|
+
const challenge = base64url(createHash("sha256").update(verifier).digest());
|
|
1254
|
+
return { verifier, challenge };
|
|
1255
|
+
}
|
|
1256
|
+
function authorizationUrl(options) {
|
|
1257
|
+
const url = new URL(options.client.endpoints.authorization);
|
|
1258
|
+
url.searchParams.set("client_id", options.client.clientId);
|
|
1259
|
+
url.searchParams.set("redirect_uri", options.redirectUri);
|
|
1260
|
+
url.searchParams.set("response_type", "code");
|
|
1261
|
+
url.searchParams.set("scope", SCOPE);
|
|
1262
|
+
url.searchParams.set("code_challenge", options.challenge);
|
|
1263
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
1264
|
+
url.searchParams.set("state", options.state);
|
|
1265
|
+
url.searchParams.set("access_type", "offline");
|
|
1266
|
+
url.searchParams.set("prompt", "consent");
|
|
1267
|
+
return url.toString();
|
|
1268
|
+
}
|
|
1269
|
+
function tokenExpiry(token) {
|
|
1270
|
+
const payload = token.split(".")[1];
|
|
1271
|
+
if (payload === undefined)
|
|
1272
|
+
return;
|
|
186
1273
|
try {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
1274
|
+
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
1275
|
+
return typeof claims.exp === "number" ? claims.exp * 1000 : undefined;
|
|
1276
|
+
} catch {
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
async function postForm(fetchImpl, url, form) {
|
|
1281
|
+
const body = new URLSearchParams;
|
|
1282
|
+
for (const [name, value] of Object.entries(form)) {
|
|
1283
|
+
if (value !== undefined && value !== "")
|
|
1284
|
+
body.set(name, value);
|
|
1285
|
+
}
|
|
1286
|
+
let response;
|
|
1287
|
+
try {
|
|
1288
|
+
response = await fetchImpl(url, {
|
|
1289
|
+
method: "POST",
|
|
1290
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
1291
|
+
body: body.toString()
|
|
1292
|
+
});
|
|
1293
|
+
} catch (error) {
|
|
1294
|
+
throw new CliError(`Could not reach ${new URL(url).host}: ${error instanceof Error ? error.message : "error"}`, "network_error", EXIT.tempfail);
|
|
1295
|
+
}
|
|
1296
|
+
const text = await response.text();
|
|
1297
|
+
let parsed = {};
|
|
1298
|
+
try {
|
|
1299
|
+
parsed = JSON.parse(text);
|
|
1300
|
+
} catch {}
|
|
1301
|
+
return { status: response.status, body: parsed };
|
|
1302
|
+
}
|
|
1303
|
+
function tokenError(body, status) {
|
|
1304
|
+
const reason = body.error_description ?? body.error ?? `HTTP ${status}`;
|
|
1305
|
+
return new CliError(`Sign-in failed: ${reason}`, body.error ?? "oauth_error", body.error === "invalid_grant" ? EXIT.config : EXIT.failure);
|
|
1306
|
+
}
|
|
1307
|
+
function toTokenSet(body, now) {
|
|
1308
|
+
const idToken = body.id_token;
|
|
1309
|
+
return {
|
|
1310
|
+
refreshToken: body.refresh_token,
|
|
1311
|
+
idToken,
|
|
1312
|
+
expiresAt: (idToken === undefined ? undefined : tokenExpiry(idToken)) ?? (body.expires_in === undefined ? undefined : now + body.expires_in * 1000)
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
async function exchangeCode(options) {
|
|
1316
|
+
const { status, body } = await postForm(options.fetch, options.client.endpoints.token, {
|
|
1317
|
+
grant_type: "authorization_code",
|
|
1318
|
+
code: options.code,
|
|
1319
|
+
code_verifier: options.verifier,
|
|
1320
|
+
redirect_uri: options.redirectUri,
|
|
1321
|
+
client_id: options.client.clientId,
|
|
1322
|
+
client_secret: options.client.clientSecret
|
|
1323
|
+
});
|
|
1324
|
+
if (status !== 200 || body.error !== undefined)
|
|
1325
|
+
throw tokenError(body, status);
|
|
1326
|
+
return toTokenSet(body, (options.now ?? Date.now)());
|
|
1327
|
+
}
|
|
1328
|
+
async function refreshIdToken(options) {
|
|
1329
|
+
const { status, body } = await postForm(options.fetch, options.client.endpoints.token, {
|
|
1330
|
+
grant_type: "refresh_token",
|
|
1331
|
+
refresh_token: options.refreshToken,
|
|
1332
|
+
client_id: options.client.clientId,
|
|
1333
|
+
client_secret: options.client.clientSecret,
|
|
1334
|
+
audience: options.audience
|
|
1335
|
+
});
|
|
1336
|
+
if (status !== 200 || body.error !== undefined)
|
|
1337
|
+
throw tokenError(body, status);
|
|
1338
|
+
const tokens = toTokenSet(body, (options.now ?? Date.now)());
|
|
1339
|
+
if (tokens.idToken === undefined) {
|
|
1340
|
+
throw new CliError("The token endpoint returned no id_token.", "oauth_error");
|
|
1341
|
+
}
|
|
1342
|
+
return { idToken: tokens.idToken, expiresAt: tokens.expiresAt };
|
|
1343
|
+
}
|
|
1344
|
+
var nodeListen = (handler) => new Promise((resolve, reject) => {
|
|
1345
|
+
const server = createServer((request, response) => {
|
|
1346
|
+
const result = handler({ url: request.url ?? "/" });
|
|
1347
|
+
response.writeHead(result.status, { "Content-Type": "text/html; charset=utf-8" });
|
|
1348
|
+
response.end(result.body);
|
|
1349
|
+
});
|
|
1350
|
+
server.on("error", reject);
|
|
1351
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1352
|
+
const { port } = server.address();
|
|
1353
|
+
resolve({
|
|
1354
|
+
port,
|
|
1355
|
+
close: () => new Promise((done) => server.close(() => done()))
|
|
1356
|
+
});
|
|
1357
|
+
});
|
|
1358
|
+
});
|
|
1359
|
+
var PAGE = (message) => `<!doctype html><meta charset="utf-8"><title>ycadence</title><p style="font:16px system-ui;margin:3rem">${message}</p>`;
|
|
1360
|
+
async function loopbackLogin(options) {
|
|
1361
|
+
const random = options.random ?? randomBytes;
|
|
1362
|
+
const { verifier, challenge } = createPkce(random);
|
|
1363
|
+
const state = base64url(random(16));
|
|
1364
|
+
const listen = options.listen ?? nodeListen;
|
|
1365
|
+
let settle = () => {};
|
|
1366
|
+
const redirected = new Promise((resolve) => {
|
|
1367
|
+
settle = resolve;
|
|
1368
|
+
});
|
|
1369
|
+
const server = await listen((request) => {
|
|
1370
|
+
const url2 = new URL(request.url, "http://127.0.0.1");
|
|
1371
|
+
if (url2.pathname !== "/")
|
|
1372
|
+
return { status: 404, body: PAGE("Not found.") };
|
|
1373
|
+
if (url2.searchParams.get("state") !== state) {
|
|
1374
|
+
return { status: 400, body: PAGE("This sign-in link is stale. Run the command again.") };
|
|
1375
|
+
}
|
|
1376
|
+
const error = url2.searchParams.get("error");
|
|
1377
|
+
const code = url2.searchParams.get("code");
|
|
1378
|
+
settle(error !== null ? { error } : { code: code ?? undefined });
|
|
1379
|
+
return {
|
|
1380
|
+
status: 200,
|
|
1381
|
+
body: PAGE(error === null ? "Signed in. You can close this tab and return to the terminal." : "Sign-in was cancelled.")
|
|
1382
|
+
};
|
|
1383
|
+
});
|
|
1384
|
+
const redirectUri = `http://127.0.0.1:${server.port}`;
|
|
1385
|
+
const url = authorizationUrl({ client: options.client, redirectUri, challenge, state });
|
|
1386
|
+
options.print(`Opening your browser to sign in. If it does not open, visit:
|
|
1387
|
+
|
|
1388
|
+
${url}
|
|
1389
|
+
`);
|
|
1390
|
+
await options.openUrl(url);
|
|
1391
|
+
let timer;
|
|
1392
|
+
const timeout = new Promise((resolve) => {
|
|
1393
|
+
timer = setTimeout(() => resolve({ error: "timed out" }), options.timeoutMs ?? 300000);
|
|
1394
|
+
});
|
|
1395
|
+
try {
|
|
1396
|
+
const result = await Promise.race([redirected, timeout]);
|
|
1397
|
+
if (result.error !== undefined || result.code === undefined) {
|
|
1398
|
+
throw new CliError(`Sign-in did not complete: ${result.error ?? "no code"}.`, "oauth_error");
|
|
1399
|
+
}
|
|
1400
|
+
return await exchangeCode({
|
|
1401
|
+
client: options.client,
|
|
1402
|
+
code: result.code,
|
|
1403
|
+
verifier,
|
|
1404
|
+
redirectUri,
|
|
1405
|
+
fetch: options.fetch,
|
|
1406
|
+
now: options.now
|
|
1407
|
+
});
|
|
1408
|
+
} finally {
|
|
1409
|
+
clearTimeout(timer);
|
|
1410
|
+
await server.close();
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
async function deviceLogin(options) {
|
|
1414
|
+
const now = options.now ?? Date.now;
|
|
1415
|
+
const start = await postForm(options.fetch, options.client.endpoints.device, {
|
|
1416
|
+
client_id: options.client.clientId,
|
|
1417
|
+
scope: SCOPE
|
|
1418
|
+
});
|
|
1419
|
+
if (start.status !== 200 || start.body.error !== undefined) {
|
|
1420
|
+
throw tokenError(start.body, start.status);
|
|
1421
|
+
}
|
|
1422
|
+
const deviceCode = start.body.device_code;
|
|
1423
|
+
const userCode = start.body.user_code;
|
|
1424
|
+
const verificationUrl = start.body.verification_url ?? start.body.verification_uri;
|
|
1425
|
+
if (deviceCode === undefined || userCode === undefined || verificationUrl === undefined) {
|
|
1426
|
+
throw new CliError("The device endpoint returned an incomplete answer.", "oauth_error");
|
|
1427
|
+
}
|
|
1428
|
+
let interval = (start.body.interval ?? 5) * 1000;
|
|
1429
|
+
const deadline = now() + (start.body.expires_in ?? 1800) * 1000;
|
|
1430
|
+
options.print(`To sign in, visit ${verificationUrl} and enter the code ${userCode}`);
|
|
1431
|
+
while (now() < deadline) {
|
|
1432
|
+
await options.sleep(interval);
|
|
1433
|
+
const { status, body } = await postForm(options.fetch, options.client.endpoints.token, {
|
|
1434
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
1435
|
+
device_code: deviceCode,
|
|
1436
|
+
client_id: options.client.clientId,
|
|
1437
|
+
client_secret: options.client.clientSecret
|
|
1438
|
+
});
|
|
1439
|
+
if (body.error === "authorization_pending")
|
|
1440
|
+
continue;
|
|
1441
|
+
if (body.error === "slow_down") {
|
|
1442
|
+
interval += 5000;
|
|
1443
|
+
continue;
|
|
1444
|
+
}
|
|
1445
|
+
if (status !== 200 || body.error !== undefined)
|
|
1446
|
+
throw tokenError(body, status);
|
|
1447
|
+
return toTokenSet(body, now());
|
|
1448
|
+
}
|
|
1449
|
+
throw new CliError("The sign-in code expired before it was approved.", "oauth_error");
|
|
1450
|
+
}
|
|
1451
|
+
function oauthClientFrom(options) {
|
|
1452
|
+
if (options.clientId === undefined || options.clientId === "") {
|
|
1453
|
+
throw new ConfigError("No OAuth client is configured. Set it with `ycadence config set oauthClientId <id>` (or YCADENCE_OAUTH_CLIENT_ID).");
|
|
1454
|
+
}
|
|
1455
|
+
return {
|
|
1456
|
+
clientId: options.clientId,
|
|
1457
|
+
clientSecret: options.clientSecret,
|
|
1458
|
+
endpoints: endpointsFrom(options.env)
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// src/identity.ts
|
|
1463
|
+
function storedIdentityToken(options) {
|
|
1464
|
+
const { config, secrets } = options;
|
|
1465
|
+
let memory;
|
|
1466
|
+
const fresh = (entry) => entry !== undefined && (entry.expiresAt === undefined || entry.expiresAt - 60000 > options.now());
|
|
1467
|
+
return async () => {
|
|
1468
|
+
if (memory !== undefined && fresh(memory))
|
|
1469
|
+
return memory.token;
|
|
1470
|
+
const cached = await secrets.get(secretAccount.identityToken(config.profile));
|
|
1471
|
+
if (cached !== undefined) {
|
|
1472
|
+
try {
|
|
1473
|
+
const parsed = JSON.parse(cached);
|
|
1474
|
+
const entry = { token: parsed.token, expiresAt: parsed.expiresAt };
|
|
1475
|
+
if (fresh(entry)) {
|
|
1476
|
+
memory = entry;
|
|
1477
|
+
return entry.token;
|
|
1478
|
+
}
|
|
1479
|
+
} catch {}
|
|
1480
|
+
}
|
|
1481
|
+
const refreshToken = await secrets.get(secretAccount.refreshToken(config.profile));
|
|
1482
|
+
if (refreshToken === undefined || config.oauthClientId === undefined)
|
|
1483
|
+
return;
|
|
1484
|
+
const clientSecret = options.env.YCADENCE_OAUTH_CLIENT_SECRET || await secrets.get(secretAccount.oauthClientSecret(config.profile));
|
|
1485
|
+
const minted = await refreshIdToken({
|
|
1486
|
+
client: oauthClientFrom({ clientId: config.oauthClientId, clientSecret, env: options.env }),
|
|
1487
|
+
refreshToken,
|
|
1488
|
+
audience: config.iapAudience,
|
|
1489
|
+
fetch: options.fetch,
|
|
1490
|
+
now: options.now
|
|
1491
|
+
});
|
|
1492
|
+
memory = { token: minted.idToken, expiresAt: minted.expiresAt };
|
|
1493
|
+
await secrets.set(secretAccount.identityToken(config.profile), JSON.stringify({ token: minted.idToken, expiresAt: minted.expiresAt }));
|
|
1494
|
+
return minted.idToken;
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// src/output.ts
|
|
1499
|
+
function pick(record, fields) {
|
|
1500
|
+
if (typeof record !== "object" || record === null)
|
|
1501
|
+
return record;
|
|
1502
|
+
const picked = {};
|
|
1503
|
+
for (const field of fields)
|
|
1504
|
+
picked[field] = record[field];
|
|
1505
|
+
return picked;
|
|
1506
|
+
}
|
|
1507
|
+
function project(data, fields) {
|
|
1508
|
+
if (fields === undefined || fields.length === 0)
|
|
1509
|
+
return data;
|
|
1510
|
+
return Array.isArray(data) ? data.map((item) => pick(item, fields)) : pick(data, fields);
|
|
1511
|
+
}
|
|
1512
|
+
function cell(value) {
|
|
1513
|
+
if (value === null || value === undefined)
|
|
1514
|
+
return "—";
|
|
1515
|
+
if (Array.isArray(value))
|
|
1516
|
+
return value.length === 0 ? "—" : value.map(cell).join(",");
|
|
1517
|
+
if (typeof value === "object")
|
|
1518
|
+
return JSON.stringify(value);
|
|
1519
|
+
return String(value);
|
|
1520
|
+
}
|
|
1521
|
+
function renderTable(rows, columns) {
|
|
1522
|
+
const cells = rows.map((row) => columns.map((column) => cell(row[column])));
|
|
1523
|
+
const widths = columns.map((column, index) => Math.max(column.length, ...cells.map((line2) => line2[index].length)));
|
|
1524
|
+
const line = (values) => values.map((value, index) => index === values.length - 1 ? value : value.padEnd(widths[index])).join(" ").trimEnd();
|
|
1525
|
+
return [line(columns.map((column) => column.toUpperCase())), ...cells.map(line)].join(`
|
|
1526
|
+
`);
|
|
1527
|
+
}
|
|
1528
|
+
function renderRecord(record, fields) {
|
|
1529
|
+
const keys = fields ?? Object.keys(record);
|
|
1530
|
+
const width = Math.max(0, ...keys.map((key) => key.length));
|
|
1531
|
+
return keys.map((key) => `${key.padEnd(width)} ${cell(record[key])}`).join(`
|
|
1532
|
+
`);
|
|
1533
|
+
}
|
|
1534
|
+
function idOf(row) {
|
|
1535
|
+
if (typeof row !== "object" || row === null)
|
|
1536
|
+
return typeof row === "string" ? row : undefined;
|
|
1537
|
+
const record = row;
|
|
1538
|
+
const value = record.key ?? record.id ?? record.runId ?? record.intakeItemId ?? record.name;
|
|
1539
|
+
return value === undefined ? undefined : String(value);
|
|
1540
|
+
}
|
|
1541
|
+
function formatSuccess(output, globals, requestId) {
|
|
1542
|
+
const data = output.data;
|
|
1543
|
+
const nextCursor = output.nextCursor ?? null;
|
|
1544
|
+
if (globals.quiet) {
|
|
1545
|
+
const ids = output.ids ?? (Array.isArray(data) ? data.map(idOf) : [idOf(data)]);
|
|
1546
|
+
return ids.filter((id) => id !== undefined).join(`
|
|
1547
|
+
`);
|
|
1548
|
+
}
|
|
1549
|
+
if (globals.format === "json") {
|
|
1550
|
+
const envelope = {
|
|
1551
|
+
ok: output.exitCode === undefined || output.exitCode === 0,
|
|
1552
|
+
data: project(data, globals.fields),
|
|
1553
|
+
nextCursor,
|
|
1554
|
+
requestId: requestId ?? null
|
|
1555
|
+
};
|
|
1556
|
+
return JSON.stringify(envelope, null, 2);
|
|
1557
|
+
}
|
|
1558
|
+
if (globals.format === "ndjson") {
|
|
1559
|
+
const projected = project(data, globals.fields);
|
|
1560
|
+
const lines = Array.isArray(projected) ? projected.map((item) => JSON.stringify(item)) : [JSON.stringify(projected)];
|
|
1561
|
+
return lines.join(`
|
|
1562
|
+
`);
|
|
1563
|
+
}
|
|
1564
|
+
if (output.text !== undefined && globals.fields === undefined)
|
|
1565
|
+
return output.text;
|
|
1566
|
+
if (Array.isArray(data)) {
|
|
1567
|
+
if (data.length === 0)
|
|
1568
|
+
return output.empty ?? "Nothing found.";
|
|
1569
|
+
const columns = globals.fields ?? output.columns ?? Object.keys(data[0] ?? {});
|
|
1570
|
+
const table = renderTable(data, columns);
|
|
1571
|
+
return nextCursor === null ? table : `${table}
|
|
1572
|
+
… more (use --cursor ${nextCursor})`;
|
|
1573
|
+
}
|
|
1574
|
+
if (typeof data === "object" && data !== null) {
|
|
1575
|
+
return renderRecord(data, globals.fields);
|
|
1576
|
+
}
|
|
1577
|
+
return data === undefined ? "" : String(data);
|
|
1578
|
+
}
|
|
1579
|
+
function formatDryRun(request, globals) {
|
|
1580
|
+
const path = (() => {
|
|
1581
|
+
try {
|
|
1582
|
+
const url = new URL(request.url);
|
|
1583
|
+
return `${url.pathname}${url.search}`;
|
|
1584
|
+
} catch {
|
|
1585
|
+
return request.url;
|
|
1586
|
+
}
|
|
1587
|
+
})();
|
|
1588
|
+
const data = { method: request.method, path, headers: request.headers, body: request.body };
|
|
1589
|
+
if (globals.format === "json" || globals.format === "ndjson") {
|
|
1590
|
+
const envelope = {
|
|
1591
|
+
ok: true,
|
|
1592
|
+
dryRun: true,
|
|
1593
|
+
data,
|
|
1594
|
+
nextCursor: null,
|
|
1595
|
+
requestId: null
|
|
1596
|
+
};
|
|
1597
|
+
return JSON.stringify(envelope, null, globals.format === "json" ? 2 : undefined);
|
|
1598
|
+
}
|
|
1599
|
+
const lines = [`Dry run: would send ${request.method} ${path}`];
|
|
1600
|
+
for (const [name, value] of Object.entries(request.headers))
|
|
1601
|
+
lines.push(` ${name}: ${value}`);
|
|
1602
|
+
if (request.body !== undefined)
|
|
1603
|
+
lines.push("", JSON.stringify(request.body, null, 2));
|
|
1604
|
+
lines.push("", "Nothing was sent.");
|
|
1605
|
+
return lines.join(`
|
|
1606
|
+
`);
|
|
1607
|
+
}
|
|
1608
|
+
function errorEnvelope(error, code, requestId) {
|
|
1609
|
+
if (error instanceof YCadenceError) {
|
|
1610
|
+
return {
|
|
1611
|
+
ok: false,
|
|
1612
|
+
error: {
|
|
1613
|
+
code: error.code,
|
|
1614
|
+
message: error.message,
|
|
1615
|
+
status: error.status,
|
|
1616
|
+
...error.issues === undefined ? {} : { issues: error.issues },
|
|
1617
|
+
...error.details === undefined ? {} : { details: error.details },
|
|
1618
|
+
requestId: error.requestId ?? requestId ?? null
|
|
210
1619
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
return {
|
|
1623
|
+
ok: false,
|
|
1624
|
+
error: {
|
|
1625
|
+
code,
|
|
1626
|
+
message: error instanceof Error ? error.message : "Unknown error.",
|
|
1627
|
+
status: null,
|
|
1628
|
+
requestId: requestId ?? null
|
|
1629
|
+
}
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
function formatError(error, code, globals, requestId) {
|
|
1633
|
+
const envelope = errorEnvelope(error, code, requestId);
|
|
1634
|
+
if (globals.format === "json")
|
|
1635
|
+
return JSON.stringify(envelope, null, 2);
|
|
1636
|
+
if (globals.format === "ndjson")
|
|
1637
|
+
return JSON.stringify(envelope);
|
|
1638
|
+
const lines = [envelope.error.message];
|
|
1639
|
+
for (const issue of envelope.error.issues ?? [])
|
|
1640
|
+
lines.push(` ${issue.path}: ${issue.message}`);
|
|
1641
|
+
if (envelope.error.requestId !== null)
|
|
1642
|
+
lines.push(`(request id ${envelope.error.requestId})`);
|
|
1643
|
+
return lines.join(`
|
|
1644
|
+
`);
|
|
1645
|
+
}
|
|
217
1646
|
|
|
218
|
-
|
|
1647
|
+
// src/resolve.ts
|
|
1648
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1649
|
+
var WORK_ITEM_KEY = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
|
|
1650
|
+
var isUuid = (value) => UUID.test(value);
|
|
1651
|
+
async function resolveProjectId(client, value) {
|
|
1652
|
+
if (isUuid(value))
|
|
1653
|
+
return value;
|
|
1654
|
+
try {
|
|
1655
|
+
const page = await client.listProjects({ q: value, limit: 50 });
|
|
1656
|
+
const match = page.items.find((project2) => project2.identifier.toLowerCase() === value.toLowerCase());
|
|
1657
|
+
if (match === undefined) {
|
|
1658
|
+
throw new CliError(`No project with identifier "${value}".`, "not_found", EXIT.notFound);
|
|
219
1659
|
}
|
|
1660
|
+
return match.id;
|
|
220
1661
|
} catch (error) {
|
|
221
|
-
if (error instanceof YCadenceError) {
|
|
222
|
-
|
|
223
|
-
${error.issues.map((issue) => ` ${issue.path}: ${issue.message}`).join(`
|
|
224
|
-
`)}`;
|
|
225
|
-
return fail(detail, error.retryable ? 75 : 1);
|
|
226
|
-
}
|
|
227
|
-
return fail(error instanceof Error ? error.message : "Unknown error.");
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
async function workItem(argv, context) {
|
|
231
|
-
const [action, ...rest] = argv;
|
|
232
|
-
switch (action) {
|
|
233
|
-
case "list": {
|
|
234
|
-
const projectId = rest[0];
|
|
235
|
-
if (projectId === undefined)
|
|
236
|
-
return fail("Usage: ycadence work-item list <projectId>");
|
|
237
|
-
const page = await context.client.listWorkItems(projectId, { limit: 50 });
|
|
238
|
-
if (page.items.length === 0)
|
|
239
|
-
return ok("No work items.");
|
|
240
|
-
const lines = page.items.map((item) => `${item.key.padEnd(10)} ${item.priority.padEnd(7)} ${item.name}`);
|
|
241
|
-
if (page.nextCursor !== null)
|
|
242
|
-
lines.push(`… more (use --cursor ${page.nextCursor})`);
|
|
243
|
-
return ok(lines.join(`
|
|
244
|
-
`));
|
|
245
|
-
}
|
|
246
|
-
case "create": {
|
|
247
|
-
const projectId = rest[0];
|
|
248
|
-
const flags = parseFlags(rest.slice(1));
|
|
249
|
-
const title = flags.title;
|
|
250
|
-
if (projectId === undefined || title === undefined) {
|
|
251
|
-
return fail('Usage: ycadence work-item create <projectId> --title "..."');
|
|
252
|
-
}
|
|
253
|
-
const created = await context.client.createWorkItem(projectId, {
|
|
254
|
-
name: title,
|
|
255
|
-
priority: flags.priority,
|
|
256
|
-
description: flags.description
|
|
257
|
-
});
|
|
258
|
-
return ok(`${created.key} ${created.name}`);
|
|
259
|
-
}
|
|
260
|
-
case "show": {
|
|
261
|
-
const workItemId = rest[0];
|
|
262
|
-
if (workItemId === undefined)
|
|
263
|
-
return fail("Usage: ycadence work-item show <workItemId>");
|
|
264
|
-
const item = await context.client.getWorkItem(workItemId);
|
|
265
|
-
return ok([
|
|
266
|
-
`${item.key} ${item.name}`,
|
|
267
|
-
`priority ${item.priority}`,
|
|
268
|
-
`state ${item.stateId ?? "—"}`,
|
|
269
|
-
"",
|
|
270
|
-
item.description ?? "(no description)"
|
|
271
|
-
].join(`
|
|
272
|
-
`));
|
|
273
|
-
}
|
|
274
|
-
case "close": {
|
|
275
|
-
const workItemId = rest[0];
|
|
276
|
-
if (workItemId === undefined)
|
|
277
|
-
return fail("Usage: ycadence work-item close <workItemId>");
|
|
278
|
-
const item = await context.client.getWorkItem(workItemId);
|
|
279
|
-
const { states } = await context.client.listStates(item.projectId ?? "");
|
|
280
|
-
const done = states.find((state) => state.group === "completed");
|
|
281
|
-
if (done === undefined) {
|
|
282
|
-
return fail("This project has no completed state, so nothing can be closed.");
|
|
283
|
-
}
|
|
284
|
-
await context.client.updateWorkItem(workItemId, { stateId: done.id });
|
|
285
|
-
return ok(`Closed ${item.key}.`);
|
|
1662
|
+
if (error instanceof YCadenceNotFoundError || error instanceof YCadenceError && error.status === 405) {
|
|
1663
|
+
throw new UsageError(`"${value}" is not a project id, and this server cannot look projects up by identifier yet. Pass the project's id.`);
|
|
286
1664
|
}
|
|
287
|
-
|
|
288
|
-
|
|
1665
|
+
throw error;
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
async function listStates(client, projectId) {
|
|
1669
|
+
const { states } = await client.listStates(projectId);
|
|
1670
|
+
return [...states].sort((a, b) => a.sequence < b.sequence ? -1 : a.sequence > b.sequence ? 1 : 0);
|
|
1671
|
+
}
|
|
1672
|
+
function findState(states, value) {
|
|
1673
|
+
const match = states.find((state) => state.id === value) ?? states.find((state) => state.name.toLowerCase() === value.toLowerCase());
|
|
1674
|
+
if (match === undefined) {
|
|
1675
|
+
throw new CliError(`No state "${value}" in this project. States: ${states.map((state) => state.name).join(", ")}.`, "validation_failed", EXIT.validation);
|
|
1676
|
+
}
|
|
1677
|
+
return match;
|
|
1678
|
+
}
|
|
1679
|
+
function closingState(states) {
|
|
1680
|
+
const done = states.find((state) => state.group === "completed");
|
|
1681
|
+
if (done === undefined) {
|
|
1682
|
+
throw new CliError("This project has no completed state, so nothing can be closed.", "validation_failed", EXIT.validation);
|
|
1683
|
+
}
|
|
1684
|
+
return done;
|
|
1685
|
+
}
|
|
1686
|
+
var KEY_SCAN_LIMIT = 2000;
|
|
1687
|
+
async function resolveWorkItemId(client, value, projectId) {
|
|
1688
|
+
if (isUuid(value))
|
|
1689
|
+
return value;
|
|
1690
|
+
if (!WORK_ITEM_KEY.test(value)) {
|
|
1691
|
+
throw new UsageError(`"${value}" is neither a work item id nor a key like DP-42.`);
|
|
1692
|
+
}
|
|
1693
|
+
const project2 = await projectId();
|
|
1694
|
+
const wanted = value.toUpperCase();
|
|
1695
|
+
let seen = 0;
|
|
1696
|
+
for await (const item of client.iterateWorkItems(project2, { pageSize: 200 })) {
|
|
1697
|
+
if (item.key.toUpperCase() === wanted)
|
|
1698
|
+
return item.id;
|
|
1699
|
+
seen += 1;
|
|
1700
|
+
if (seen >= KEY_SCAN_LIMIT)
|
|
1701
|
+
break;
|
|
1702
|
+
}
|
|
1703
|
+
throw new CliError(seen >= KEY_SCAN_LIMIT ? `${value} was not among the first ${KEY_SCAN_LIMIT} items; pass its id instead.` : `No work item ${value} in this project.`, "not_found", EXIT.notFound);
|
|
1704
|
+
}
|
|
1705
|
+
function projectOf(item) {
|
|
1706
|
+
return typeof item.projectId === "string" && item.projectId !== "" ? item.projectId : undefined;
|
|
1707
|
+
}
|
|
289
1708
|
|
|
290
|
-
|
|
1709
|
+
// src/completion.ts
|
|
1710
|
+
var SHELLS = ["bash", "zsh", "fish"];
|
|
1711
|
+
function entries(root) {
|
|
1712
|
+
const globalFlags = GLOBAL_FLAGS.map((flag) => `--${flag.name}`);
|
|
1713
|
+
const all = [
|
|
1714
|
+
{
|
|
1715
|
+
key: "",
|
|
1716
|
+
words: visibleChildren(root).flatMap((child) => [child.name, ...child.aliases ?? []]),
|
|
1717
|
+
flags: globalFlags
|
|
1718
|
+
}
|
|
1719
|
+
];
|
|
1720
|
+
for (const { path, command } of walk(root)) {
|
|
1721
|
+
all.push({
|
|
1722
|
+
key: path.join(" "),
|
|
1723
|
+
words: visibleChildren(command).flatMap((child) => [child.name, ...child.aliases ?? []]),
|
|
1724
|
+
flags: [
|
|
1725
|
+
...globalFlags,
|
|
1726
|
+
...(command.flags ?? []).filter((flag) => flag.hidden !== true).map((flag) => `--${flag.name}`)
|
|
1727
|
+
]
|
|
1728
|
+
});
|
|
1729
|
+
for (const alias of command.aliases ?? []) {
|
|
1730
|
+
const last = all[all.length - 1];
|
|
1731
|
+
all.push({ ...last, key: [...path.slice(0, -1), alias].join(" ") });
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
return all;
|
|
1735
|
+
}
|
|
1736
|
+
function enumValues(root) {
|
|
1737
|
+
const values = new Map;
|
|
1738
|
+
for (const flag of GLOBAL_FLAGS)
|
|
1739
|
+
if (flag.values)
|
|
1740
|
+
values.set(`--${flag.name}`, flag.values);
|
|
1741
|
+
for (const { command } of walk(root)) {
|
|
1742
|
+
for (const flag of command.flags ?? [])
|
|
1743
|
+
if (flag.values)
|
|
1744
|
+
values.set(`--${flag.name}`, flag.values);
|
|
291
1745
|
}
|
|
1746
|
+
return values;
|
|
1747
|
+
}
|
|
1748
|
+
function bash(root) {
|
|
1749
|
+
const table = entries(root).map((entry) => ` "${entry.key}") words="${[...entry.words, ...entry.flags].join(" ")}" ;;`).join(`
|
|
1750
|
+
`);
|
|
1751
|
+
const values = [...enumValues(root)].map(([flag, list]) => ` ${flag}) COMPREPLY=($(compgen -W "${list.join(" ")}" -- "$cur")); return ;;`).join(`
|
|
1752
|
+
`);
|
|
1753
|
+
return `# bash completion for ycadence (generated; do not edit)
|
|
1754
|
+
# Add to ~/.bashrc: source <(ycadence completion bash)
|
|
1755
|
+
_ycadence() {
|
|
1756
|
+
local cur prev cmdpath words i
|
|
1757
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
1758
|
+
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
1759
|
+
case "$prev" in
|
|
1760
|
+
${values}
|
|
1761
|
+
esac
|
|
1762
|
+
cmdpath=""
|
|
1763
|
+
for ((i = 1; i < COMP_CWORD; i++)); do
|
|
1764
|
+
case "\${COMP_WORDS[i]}" in
|
|
1765
|
+
-*) ;;
|
|
1766
|
+
*) if _ycadence_known "\${cmdpath:+$cmdpath }\${COMP_WORDS[i]}"; then cmdpath="\${cmdpath:+$cmdpath }\${COMP_WORDS[i]}"; fi ;;
|
|
1767
|
+
esac
|
|
1768
|
+
done
|
|
1769
|
+
_ycadence_words "$cmdpath"
|
|
1770
|
+
COMPREPLY=($(compgen -W "$words" -- "$cur"))
|
|
1771
|
+
}
|
|
1772
|
+
_ycadence_words() {
|
|
1773
|
+
case "$1" in
|
|
1774
|
+
${table}
|
|
1775
|
+
*) words="" ;;
|
|
1776
|
+
esac
|
|
292
1777
|
}
|
|
1778
|
+
_ycadence_known() {
|
|
1779
|
+
local words
|
|
1780
|
+
_ycadence_words "$1"
|
|
1781
|
+
[ -n "$words" ]
|
|
1782
|
+
}
|
|
1783
|
+
complete -F _ycadence ycadence devpulse
|
|
1784
|
+
`;
|
|
1785
|
+
}
|
|
1786
|
+
function zsh(root) {
|
|
1787
|
+
const table = entries(root).map((entry) => ` "${entry.key}") reply=(${[...entry.words, ...entry.flags].join(" ")}) ;;`).join(`
|
|
1788
|
+
`);
|
|
1789
|
+
const values = [...enumValues(root)].map(([flag, list]) => ` ${flag}) compadd -- ${list.join(" ")}; return ;;`).join(`
|
|
1790
|
+
`);
|
|
1791
|
+
return `#compdef ycadence devpulse
|
|
1792
|
+
# zsh completion for ycadence (generated; do not edit)
|
|
1793
|
+
# Add to ~/.zshrc (after compinit): source <(ycadence completion zsh)
|
|
1794
|
+
_ycadence_words() {
|
|
1795
|
+
case "$1" in
|
|
1796
|
+
${table}
|
|
1797
|
+
*) reply=() ;;
|
|
1798
|
+
esac
|
|
1799
|
+
}
|
|
1800
|
+
_ycadence() {
|
|
1801
|
+
local -a reply
|
|
1802
|
+
local cmdpath="" word i
|
|
1803
|
+
case "\${words[CURRENT-1]}" in
|
|
1804
|
+
${values}
|
|
1805
|
+
esac
|
|
1806
|
+
for (( i = 2; i < CURRENT; i++ )); do
|
|
1807
|
+
word="\${words[i]}"
|
|
1808
|
+
[[ "$word" == -* ]] && continue
|
|
1809
|
+
_ycadence_words "\${cmdpath:+$cmdpath }$word"
|
|
1810
|
+
(( \${#reply} )) && cmdpath="\${cmdpath:+$cmdpath }$word"
|
|
1811
|
+
done
|
|
1812
|
+
_ycadence_words "$cmdpath"
|
|
1813
|
+
compadd -- $reply
|
|
1814
|
+
}
|
|
1815
|
+
compdef _ycadence ycadence devpulse
|
|
1816
|
+
`;
|
|
1817
|
+
}
|
|
1818
|
+
function fish(root) {
|
|
1819
|
+
const lines = [
|
|
1820
|
+
"# fish completion for ycadence (generated; do not edit)",
|
|
1821
|
+
"# Save as ~/.config/fish/completions/ycadence.fish: ycadence completion fish > ~/.config/fish/completions/ycadence.fish",
|
|
1822
|
+
"function __ycadence_path",
|
|
1823
|
+
" set -l tokens (commandline -opc)",
|
|
1824
|
+
" set -e tokens[1]",
|
|
1825
|
+
" set -l words",
|
|
1826
|
+
" for token in $tokens",
|
|
1827
|
+
" string match -q -- '-*' $token; or set words $words $token",
|
|
1828
|
+
" end",
|
|
1829
|
+
' string join " " $words',
|
|
1830
|
+
"end"
|
|
1831
|
+
];
|
|
1832
|
+
for (const entry of entries(root)) {
|
|
1833
|
+
const condition = `test (__ycadence_path) = '${entry.key}'`;
|
|
1834
|
+
if (entry.words.length > 0) {
|
|
1835
|
+
lines.push(`complete -c ycadence -f -n "${condition}" -a '${entry.words.join(" ")}'`);
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
for (const flag of GLOBAL_FLAGS) {
|
|
1839
|
+
const values = flag.values ? ` -a '${flag.values.join(" ")}'` : "";
|
|
1840
|
+
lines.push(`complete -c ycadence -l ${flag.name}${flag.short ? ` -s ${flag.short}` : ""}${flag.type === "boolean" ? "" : " -r"}${values} -d '${flag.description.replace(/'/g, "\\'")}'`);
|
|
1841
|
+
}
|
|
1842
|
+
for (const { path, command } of walk(root)) {
|
|
1843
|
+
for (const flag of command.flags ?? []) {
|
|
1844
|
+
if (flag.hidden === true)
|
|
1845
|
+
continue;
|
|
1846
|
+
const values = flag.values ? ` -a '${flag.values.join(" ")}'` : "";
|
|
1847
|
+
lines.push(`complete -c ycadence -n "test (__ycadence_path) = '${path.join(" ")}'" -l ${flag.name}${flag.type === "boolean" ? "" : " -r"}${values} -d '${flag.description.replace(/'/g, "\\'")}'`);
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
lines.push("complete -c devpulse -w ycadence", "");
|
|
1851
|
+
return lines.join(`
|
|
1852
|
+
`);
|
|
1853
|
+
}
|
|
1854
|
+
function completionScript(root, shell) {
|
|
1855
|
+
if (shell === "bash")
|
|
1856
|
+
return bash(root);
|
|
1857
|
+
if (shell === "zsh")
|
|
1858
|
+
return zsh(root);
|
|
1859
|
+
return fish(root);
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
// src/handlers/auth.ts
|
|
1863
|
+
function str(value) {
|
|
1864
|
+
return typeof value === "string" ? value : undefined;
|
|
1865
|
+
}
|
|
1866
|
+
function keyHint(key) {
|
|
1867
|
+
return `${key.slice(0, 12)}… (${key.length} chars)`;
|
|
1868
|
+
}
|
|
1869
|
+
async function storedKey(context) {
|
|
1870
|
+
return readEnv("API_KEY", context.env) ?? await context.secrets.get(secretAccount.apiKey(context.config.profile));
|
|
1871
|
+
}
|
|
1872
|
+
var loginHandler = async (context) => {
|
|
1873
|
+
const profile = context.config.profile;
|
|
1874
|
+
assertProfileName(profile);
|
|
1875
|
+
const file = await context.configStore.load();
|
|
1876
|
+
const stored = { ...file.profiles[profile] ?? {} };
|
|
1877
|
+
const url = str(context.flags.url);
|
|
1878
|
+
if (url !== undefined) {
|
|
1879
|
+
try {
|
|
1880
|
+
new URL(url);
|
|
1881
|
+
} catch {
|
|
1882
|
+
throw new UsageError(`--url takes a URL, got "${url}".`);
|
|
1883
|
+
}
|
|
1884
|
+
stored.url = url.replace(/\/+$/, "");
|
|
1885
|
+
}
|
|
1886
|
+
const clientIdFlag = str(context.flags["oauth-client-id"]);
|
|
1887
|
+
if (clientIdFlag !== undefined)
|
|
1888
|
+
stored.oauthClientId = clientIdFlag;
|
|
1889
|
+
const audienceFlag = str(context.flags["iap-audience"]);
|
|
1890
|
+
if (audienceFlag !== undefined)
|
|
1891
|
+
stored.iapAudience = audienceFlag;
|
|
1892
|
+
file.profiles[profile] = stored;
|
|
1893
|
+
if (file.currentProfile === undefined)
|
|
1894
|
+
file.currentProfile = profile;
|
|
1895
|
+
await context.configStore.save(file);
|
|
1896
|
+
let apiKey;
|
|
1897
|
+
if (context.flags["api-key-stdin"] === true) {
|
|
1898
|
+
apiKey = (await context.readStdin()).trim();
|
|
1899
|
+
if (apiKey === "")
|
|
1900
|
+
throw new UsageError("--api-key-stdin read nothing from stdin.");
|
|
1901
|
+
}
|
|
1902
|
+
const clientId = readEnv("OAUTH_CLIENT_ID", context.env) ?? stored.oauthClientId;
|
|
1903
|
+
const audience = readEnv("IAP_AUDIENCE", context.env) ?? stored.iapAudience;
|
|
1904
|
+
let signedIn = false;
|
|
1905
|
+
if (context.flags["skip-oauth"] === true) {
|
|
1906
|
+
context.debug("Skipping the IAP sign-in (--skip-oauth).");
|
|
1907
|
+
} else if (clientId === undefined) {
|
|
1908
|
+
context.notice("No OAuth client is configured, so the IAP sign-in is skipped. That is right for an instance that is not behind IAP. For one that is, a person must first create the Desktop OAuth client (see docs/cli/work-management.md), then run `ycadence login --oauth-client-id <id> --iap-audience <IAP client id>`.");
|
|
1909
|
+
} else {
|
|
1910
|
+
const clientSecret = context.env.YCADENCE_OAUTH_CLIENT_SECRET || await context.secrets.get(secretAccount.oauthClientSecret(profile));
|
|
1911
|
+
if (clientSecret === undefined || clientSecret === "") {
|
|
1912
|
+
throw new ConfigError("The OAuth client secret is missing. Set YCADENCE_OAUTH_CLIENT_SECRET for this login; it is then kept in the credential store.");
|
|
1913
|
+
}
|
|
1914
|
+
const client = oauthClientFrom({ clientId, clientSecret, env: context.env });
|
|
1915
|
+
const tokens = context.flags.device === true ? await deviceLogin({
|
|
1916
|
+
client,
|
|
1917
|
+
fetch: context.fetch,
|
|
1918
|
+
print: (message) => context.notice(message),
|
|
1919
|
+
sleep: (ms) => context.sleep(ms),
|
|
1920
|
+
now: () => context.now()
|
|
1921
|
+
}) : await loopbackLogin({
|
|
1922
|
+
client,
|
|
1923
|
+
fetch: context.fetch,
|
|
1924
|
+
openUrl: context.flags["no-browser"] === true ? async () => false : (target) => context.openUrl(target),
|
|
1925
|
+
print: (message) => context.notice(message),
|
|
1926
|
+
now: () => context.now()
|
|
1927
|
+
});
|
|
1928
|
+
if (tokens.refreshToken === undefined) {
|
|
1929
|
+
throw new CliError("Google returned no refresh token. Revoke the app at myaccount.google.com/permissions and sign in again.", "oauth_error");
|
|
1930
|
+
}
|
|
1931
|
+
await context.secrets.set(secretAccount.refreshToken(profile), tokens.refreshToken);
|
|
1932
|
+
await context.secrets.set(secretAccount.oauthClientSecret(profile), clientSecret);
|
|
1933
|
+
const identity = audience === undefined ? tokens.idToken === undefined ? undefined : { idToken: tokens.idToken, expiresAt: tokens.expiresAt } : await refreshIdToken({
|
|
1934
|
+
client,
|
|
1935
|
+
refreshToken: tokens.refreshToken,
|
|
1936
|
+
audience,
|
|
1937
|
+
fetch: context.fetch,
|
|
1938
|
+
now: () => context.now()
|
|
1939
|
+
});
|
|
1940
|
+
if (identity === undefined) {
|
|
1941
|
+
await context.secrets.delete(secretAccount.identityToken(profile));
|
|
1942
|
+
} else {
|
|
1943
|
+
await context.secrets.set(secretAccount.identityToken(profile), JSON.stringify({ token: identity.idToken, expiresAt: identity.expiresAt }));
|
|
1944
|
+
}
|
|
1945
|
+
signedIn = true;
|
|
1946
|
+
}
|
|
1947
|
+
if (apiKey !== undefined)
|
|
1948
|
+
await context.secrets.set(secretAccount.apiKey(profile), apiKey);
|
|
1949
|
+
await context.reloadConfig();
|
|
1950
|
+
const key = await storedKey(context);
|
|
1951
|
+
const result = {
|
|
1952
|
+
profile,
|
|
1953
|
+
url: context.config.url ?? null,
|
|
1954
|
+
identity: signedIn,
|
|
1955
|
+
apiKey: key === undefined ? null : keyHint(key),
|
|
1956
|
+
credentialStore: context.secrets.kind,
|
|
1957
|
+
organization: null
|
|
1958
|
+
};
|
|
1959
|
+
if (context.config.url !== undefined && key !== undefined) {
|
|
1960
|
+
const client = await context.client();
|
|
1961
|
+
try {
|
|
1962
|
+
const me = await client.me();
|
|
1963
|
+
result.organization = me.organization.slug;
|
|
1964
|
+
} catch (error) {
|
|
1965
|
+
if (!(error instanceof YCadenceNotFoundError))
|
|
1966
|
+
throw error;
|
|
1967
|
+
await client.health();
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
const lines = [`Profile "${profile}" saved.`];
|
|
1971
|
+
if (signedIn)
|
|
1972
|
+
lines.push("Signed in for IAP; the identity token refreshes itself.");
|
|
1973
|
+
if (key !== undefined)
|
|
1974
|
+
lines.push(`API key ${keyHint(key)} in the ${context.secrets.description}.`);
|
|
1975
|
+
else
|
|
1976
|
+
lines.push("No API key yet: run `ycadence login --api-key-stdin < key.txt`.");
|
|
1977
|
+
if (result.organization !== null)
|
|
1978
|
+
lines.push(`Connected to organization ${result.organization}.`);
|
|
1979
|
+
return { data: result, text: lines.join(`
|
|
1980
|
+
`) };
|
|
1981
|
+
};
|
|
1982
|
+
var statusHandler = async (context) => {
|
|
1983
|
+
const key = await storedKey(context);
|
|
1984
|
+
if (context.config.url === undefined || key === undefined) {
|
|
1985
|
+
throw new ConfigError("Not signed in: no URL or API key for this profile. Run `ycadence login`.");
|
|
1986
|
+
}
|
|
1987
|
+
const refresh = await context.secrets.get(secretAccount.refreshToken(context.config.profile));
|
|
1988
|
+
const data = {
|
|
1989
|
+
profile: context.config.profile,
|
|
1990
|
+
url: context.config.url,
|
|
1991
|
+
apiKey: keyHint(key),
|
|
1992
|
+
identity: refresh !== undefined,
|
|
1993
|
+
me: null
|
|
1994
|
+
};
|
|
1995
|
+
try {
|
|
1996
|
+
data.me = await (await context.client()).me();
|
|
1997
|
+
} catch (error) {
|
|
1998
|
+
if (!(error instanceof YCadenceNotFoundError))
|
|
1999
|
+
throw error;
|
|
2000
|
+
data.note = "This server does not have GET /api/v1/me yet.";
|
|
2001
|
+
}
|
|
2002
|
+
const me = data.me;
|
|
2003
|
+
const lines = [
|
|
2004
|
+
`profile ${data.profile}`,
|
|
2005
|
+
`url ${data.url}`,
|
|
2006
|
+
`api key ${data.apiKey}`,
|
|
2007
|
+
`identity ${data.identity === true ? "signed in (IAP)" : "not signed in"}`
|
|
2008
|
+
];
|
|
2009
|
+
if (me !== null) {
|
|
2010
|
+
lines.push(`org ${me.organization?.slug ?? "—"}`);
|
|
2011
|
+
lines.push(`as ${me.principal?.email ?? me.principal?.kind ?? "—"}`);
|
|
2012
|
+
lines.push(`scopes ${(me.scopes ?? []).join(", ") || "—"}`);
|
|
2013
|
+
if (context.globals.org !== undefined && me.organization?.slug !== context.globals.org) {
|
|
2014
|
+
context.notice(`This key belongs to ${me.organization?.slug}, not ${context.globals.org}.`);
|
|
2015
|
+
}
|
|
2016
|
+
} else {
|
|
2017
|
+
lines.push(` ${data.note}`);
|
|
2018
|
+
}
|
|
2019
|
+
return { data, text: lines.join(`
|
|
2020
|
+
`) };
|
|
2021
|
+
};
|
|
2022
|
+
var logoutHandler = async (context) => {
|
|
2023
|
+
const profile = context.config.profile;
|
|
2024
|
+
for (const account of [
|
|
2025
|
+
secretAccount.apiKey(profile),
|
|
2026
|
+
secretAccount.refreshToken(profile),
|
|
2027
|
+
secretAccount.identityToken(profile),
|
|
2028
|
+
secretAccount.oauthClientSecret(profile)
|
|
2029
|
+
]) {
|
|
2030
|
+
await context.secrets.delete(account);
|
|
2031
|
+
}
|
|
2032
|
+
return {
|
|
2033
|
+
data: { profile, loggedOut: true },
|
|
2034
|
+
text: `Removed the stored credentials for profile "${profile}". Its settings are kept; \`ycadence config list\` shows them.`
|
|
2035
|
+
};
|
|
2036
|
+
};
|
|
2037
|
+
var tokenHandler = async (context) => {
|
|
2038
|
+
const fromEnv = readEnv("IDENTITY_TOKEN", context.env);
|
|
2039
|
+
const token = fromEnv ?? await storedIdentityToken({
|
|
2040
|
+
config: context.config,
|
|
2041
|
+
secrets: context.secrets,
|
|
2042
|
+
env: context.env,
|
|
2043
|
+
fetch: context.fetch,
|
|
2044
|
+
now: () => context.now()
|
|
2045
|
+
})();
|
|
2046
|
+
if (token === undefined) {
|
|
2047
|
+
throw new ConfigError("No identity token: run `ycadence login` against an IAP instance first.");
|
|
2048
|
+
}
|
|
2049
|
+
return { data: { token }, text: token, ids: [token] };
|
|
2050
|
+
};
|
|
2051
|
+
|
|
2052
|
+
// src/handlers/config.ts
|
|
2053
|
+
function str2(value) {
|
|
2054
|
+
return typeof value === "string" ? value : undefined;
|
|
2055
|
+
}
|
|
2056
|
+
function validate(key, value) {
|
|
2057
|
+
if (key === "url") {
|
|
2058
|
+
try {
|
|
2059
|
+
new URL(value);
|
|
2060
|
+
} catch {
|
|
2061
|
+
throw new UsageError(`url takes a URL, got "${value}".`);
|
|
2062
|
+
}
|
|
2063
|
+
return value.replace(/\/+$/, "");
|
|
2064
|
+
}
|
|
2065
|
+
if (key === "format" && !["table", "json", "ndjson"].includes(value)) {
|
|
2066
|
+
throw new UsageError("format is one of table, json, ndjson.");
|
|
2067
|
+
}
|
|
2068
|
+
return value;
|
|
2069
|
+
}
|
|
2070
|
+
var configGetHandler = async (context) => {
|
|
2071
|
+
const key = str2(context.args.key);
|
|
2072
|
+
assertProfileKey(key);
|
|
2073
|
+
const value = (await context.configStore.load()).profiles[context.config.profile]?.[key];
|
|
2074
|
+
return { data: { profile: context.config.profile, key, value: value ?? null }, text: value ?? "" };
|
|
2075
|
+
};
|
|
2076
|
+
var configSetHandler = async (context) => {
|
|
2077
|
+
const key = str2(context.args.key);
|
|
2078
|
+
assertProfileKey(key);
|
|
2079
|
+
const value = validate(key, str2(context.args.value));
|
|
2080
|
+
const file = await context.configStore.load();
|
|
2081
|
+
const profile = context.config.profile;
|
|
2082
|
+
file.profiles[profile] = { ...file.profiles[profile] ?? {}, [key]: value };
|
|
2083
|
+
if (file.currentProfile === undefined)
|
|
2084
|
+
file.currentProfile = profile;
|
|
2085
|
+
await context.configStore.save(file);
|
|
2086
|
+
return { data: { profile, key, value }, text: `Set ${key} for profile "${profile}".` };
|
|
2087
|
+
};
|
|
2088
|
+
var configUnsetHandler = async (context) => {
|
|
2089
|
+
const key = str2(context.args.key);
|
|
2090
|
+
assertProfileKey(key);
|
|
2091
|
+
const file = await context.configStore.load();
|
|
2092
|
+
const profile = context.config.profile;
|
|
2093
|
+
const stored = { ...file.profiles[profile] ?? {} };
|
|
2094
|
+
delete stored[key];
|
|
2095
|
+
file.profiles[profile] = stored;
|
|
2096
|
+
await context.configStore.save(file);
|
|
2097
|
+
return { data: { profile, key, value: null }, text: `Unset ${key} for profile "${profile}".` };
|
|
2098
|
+
};
|
|
2099
|
+
var configListHandler = async (context) => {
|
|
2100
|
+
const config = context.config;
|
|
2101
|
+
const rows = PROFILE_KEYS.map((key) => {
|
|
2102
|
+
const value = config[key];
|
|
2103
|
+
const source = config.sources[key] ?? (value === undefined ? "none" : "profile");
|
|
2104
|
+
return { key, value: value ?? null, source, description: PROFILE_KEY_HELP[key] };
|
|
2105
|
+
});
|
|
2106
|
+
return {
|
|
2107
|
+
data: { profile: config.profile, path: context.configStore.path ?? null, settings: rows },
|
|
2108
|
+
text: [
|
|
2109
|
+
`profile "${config.profile}" (${context.configStore.path ?? "not saved"})`,
|
|
2110
|
+
...rows.map((row) => ` ${row.key.padEnd(14)} ${String(row.value ?? "—").padEnd(40)} ${row.source}`)
|
|
2111
|
+
].join(`
|
|
2112
|
+
`)
|
|
2113
|
+
};
|
|
2114
|
+
};
|
|
2115
|
+
var configProfilesHandler = async (context) => {
|
|
2116
|
+
const file = await context.configStore.load();
|
|
2117
|
+
const current = context.config.profile;
|
|
2118
|
+
const names = Object.keys(file.profiles).sort();
|
|
2119
|
+
const rows = names.map((name) => ({
|
|
2120
|
+
name,
|
|
2121
|
+
current: name === current,
|
|
2122
|
+
url: file.profiles[name]?.url ?? null
|
|
2123
|
+
}));
|
|
2124
|
+
return {
|
|
2125
|
+
data: rows,
|
|
2126
|
+
columns: ["name", "current", "url"],
|
|
2127
|
+
empty: "No profiles yet. `ycadence login` creates one.",
|
|
2128
|
+
text: rows.length === 0 ? "No profiles yet. `ycadence login` creates one." : rows.map((row) => `${row.current ? "*" : " "} ${row.name.padEnd(16)} ${row.url ?? "—"}`).join(`
|
|
2129
|
+
`)
|
|
2130
|
+
};
|
|
2131
|
+
};
|
|
2132
|
+
var configUseHandler = async (context) => {
|
|
2133
|
+
const name = str2(context.args.profile);
|
|
2134
|
+
assertProfileName(name);
|
|
2135
|
+
const file = await context.configStore.load();
|
|
2136
|
+
file.currentProfile = name;
|
|
2137
|
+
file.profiles[name] = file.profiles[name] ?? {};
|
|
2138
|
+
await context.configStore.save(file);
|
|
2139
|
+
return { data: { currentProfile: name }, text: `Now using profile "${name}".` };
|
|
2140
|
+
};
|
|
2141
|
+
var configPathHandler = async (context) => {
|
|
2142
|
+
const path = context.configStore.path ?? null;
|
|
2143
|
+
return { data: { path }, text: path ?? "No config directory (no HOME or XDG_CONFIG_HOME)." };
|
|
2144
|
+
};
|
|
2145
|
+
|
|
2146
|
+
// src/version.ts
|
|
2147
|
+
var CLI_VERSION = "0.2.1";
|
|
2148
|
+
|
|
2149
|
+
// src/handlers/doctor.ts
|
|
293
2150
|
function legacyNote(name, env) {
|
|
294
2151
|
const current = env[`YCADENCE_${name}`];
|
|
295
2152
|
if (current !== undefined && current !== "")
|
|
296
2153
|
return "";
|
|
297
2154
|
return ` (read from ${LEGACY_ENV_PREFIX}${name}; rename it to YCADENCE_${name})`;
|
|
298
2155
|
}
|
|
299
|
-
async
|
|
2156
|
+
var doctorHandler = async (context) => {
|
|
300
2157
|
const lines = [];
|
|
2158
|
+
const checks = [];
|
|
301
2159
|
let healthy = true;
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
2160
|
+
const add = (name, ok, line, detail = line) => {
|
|
2161
|
+
lines.push(line);
|
|
2162
|
+
checks.push({ name, ok, detail });
|
|
2163
|
+
if (ok === false)
|
|
2164
|
+
healthy = false;
|
|
2165
|
+
};
|
|
2166
|
+
const env = context.env;
|
|
2167
|
+
const profile = context.config.profile;
|
|
2168
|
+
lines.push(`ycadence ${CLI_VERSION}, profile "${profile}"`);
|
|
2169
|
+
const envUrl = readEnv("URL", env);
|
|
2170
|
+
const url = context.config.url;
|
|
2171
|
+
if (url === undefined)
|
|
2172
|
+
add("url", false, "✗ YCADENCE_URL is not set");
|
|
2173
|
+
else if (envUrl !== undefined)
|
|
2174
|
+
add("url", true, `✓ YCADENCE_URL ${url}${legacyNote("URL", env)}`);
|
|
2175
|
+
else
|
|
2176
|
+
add("url", true, `✓ url ${url} (profile "${profile}")`);
|
|
2177
|
+
const envKey = readEnv("API_KEY", env);
|
|
2178
|
+
const storedKey2 = envKey === undefined ? await context.secrets.get(secretAccount.apiKey(profile)) : undefined;
|
|
2179
|
+
const key = envKey ?? storedKey2;
|
|
2180
|
+
if (key === undefined)
|
|
2181
|
+
add("apiKey", false, "✗ YCADENCE_API_KEY is not set");
|
|
2182
|
+
else if (envKey !== undefined) {
|
|
2183
|
+
add("apiKey", true, `✓ YCADENCE_API_KEY ${keyHint(key)}${legacyNote("API_KEY", env)}`);
|
|
2184
|
+
} else
|
|
2185
|
+
add("apiKey", true, `✓ API key ${keyHint(key)} (${context.secrets.description})`);
|
|
2186
|
+
add("credentialStore", context.secrets.kind === "file" ? null : true, context.secrets.kind === "file" ? `! credentials in ${context.secrets.description}` : `✓ credentials in ${context.secrets.description}`);
|
|
2187
|
+
const identityToken = readEnv("IDENTITY_TOKEN", env);
|
|
2188
|
+
const refresh = await context.secrets.get(secretAccount.refreshToken(profile));
|
|
2189
|
+
if (identityToken !== undefined && identityToken.length > 0) {
|
|
2190
|
+
add("identity", true, `✓ YCADENCE_IDENTITY_TOKEN set (${identityToken.length} chars)${legacyNote("IDENTITY_TOKEN", env)}`);
|
|
2191
|
+
} else if (refresh !== undefined) {
|
|
2192
|
+
add("identity", true, "✓ signed in for IAP (`ycadence login`)");
|
|
2193
|
+
} else {
|
|
2194
|
+
add("identity", null, "· YCADENCE_IDENTITY_TOKEN not set (only needed behind IAP)");
|
|
2195
|
+
}
|
|
2196
|
+
if (url !== undefined && key !== undefined) {
|
|
2197
|
+
const client = await context.client();
|
|
311
2198
|
try {
|
|
312
|
-
const health = await
|
|
313
|
-
|
|
314
|
-
for (const [name, state] of Object.entries(health.checks)) {
|
|
315
|
-
|
|
316
|
-
if (state !== "ok")
|
|
317
|
-
healthy = false;
|
|
2199
|
+
const health = await client.health();
|
|
2200
|
+
add("reachable", true, `✓ reachable — status ${health.status}`);
|
|
2201
|
+
for (const [name, state] of Object.entries(health.checks ?? {})) {
|
|
2202
|
+
add(`health.${name}`, state === "ok", ` ${state === "ok" ? "✓" : "✗"} ${name}: ${state}`);
|
|
318
2203
|
}
|
|
319
2204
|
} catch (error) {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
`
|
|
326
|
-
|
|
327
|
-
}
|
|
328
|
-
function parseFlags(argv) {
|
|
329
|
-
const flags = {};
|
|
330
|
-
for (let index = 0;index < argv.length; index += 1) {
|
|
331
|
-
const token = argv[index];
|
|
332
|
-
if (!token.startsWith("--"))
|
|
333
|
-
continue;
|
|
334
|
-
const equals = token.indexOf("=");
|
|
335
|
-
if (equals > -1) {
|
|
336
|
-
flags[token.slice(2, equals)] = token.slice(equals + 1);
|
|
337
|
-
continue;
|
|
2205
|
+
const message = error instanceof Error ? error.message : "unknown error";
|
|
2206
|
+
add("reachable", false, `✗ unreachable — ${message}`);
|
|
2207
|
+
if (/audience/i.test(message)) {
|
|
2208
|
+
lines.push(" The identity token was minted for the wrong client. Set iapAudience to the IAP OAuth client id and run `ycadence login` again.");
|
|
2209
|
+
} else if (error instanceof YCadenceError && error.status === 401 && refresh === undefined && identityToken === undefined) {
|
|
2210
|
+
lines.push(" If this instance is behind IAP, sign in with `ycadence login` first.");
|
|
2211
|
+
}
|
|
338
2212
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
2213
|
+
if (healthy) {
|
|
2214
|
+
try {
|
|
2215
|
+
const me = await client.me();
|
|
2216
|
+
if (typeof me === "object" && me !== null && "organization" in me) {
|
|
2217
|
+
add("key", true, `✓ key accepted — organization ${me.organization.slug}`);
|
|
2218
|
+
}
|
|
2219
|
+
} catch (error) {
|
|
2220
|
+
if (error instanceof YCadenceNotFoundError) {
|
|
2221
|
+
add("key", null, "· key not checked (this server has no /api/v1/me yet)");
|
|
2222
|
+
} else {
|
|
2223
|
+
add("key", false, `✗ key refused — ${error instanceof Error ? error.message : "error"}`);
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
343
2226
|
}
|
|
344
|
-
flags[token.slice(2)] = next;
|
|
345
|
-
index += 1;
|
|
346
2227
|
}
|
|
347
|
-
return
|
|
348
|
-
}
|
|
2228
|
+
return {
|
|
2229
|
+
data: { healthy, profile, checks },
|
|
2230
|
+
text: lines.join(`
|
|
2231
|
+
`),
|
|
2232
|
+
exitCode: healthy ? EXIT.ok : EXIT.failure
|
|
2233
|
+
};
|
|
2234
|
+
};
|
|
2235
|
+
|
|
2236
|
+
// src/handlers/imports.ts
|
|
349
2237
|
var IMPORT_SOURCES = ["csv", "github", "jira", "linear"];
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
return fail("Usage: ycadence import status <runId>");
|
|
356
|
-
const run2 = await context.client.getImport(target);
|
|
357
|
-
return ok(formatRun(run2));
|
|
358
|
-
}
|
|
359
|
-
if (action === "list") {
|
|
360
|
-
if (target === undefined)
|
|
361
|
-
return fail("Usage: ycadence import list <projectId>");
|
|
362
|
-
const { runs } = await context.client.listImports(target);
|
|
363
|
-
return ok(runs.length === 0 ? "No imports." : runs.map((run2) => formatRun(run2)).join(`
|
|
364
|
-
|
|
365
|
-
`));
|
|
366
|
-
}
|
|
367
|
-
if (action !== "preview" && action !== "run") {
|
|
368
|
-
return fail(`Usage: ycadence import preview|run|status|list …`);
|
|
369
|
-
}
|
|
370
|
-
if (target === undefined)
|
|
371
|
-
return fail(`Usage: ycadence import ${action} <projectId> --file …`);
|
|
372
|
-
const source = flags.source ?? "csv";
|
|
2238
|
+
function str3(value) {
|
|
2239
|
+
return typeof value === "string" ? value : undefined;
|
|
2240
|
+
}
|
|
2241
|
+
async function importBody(context) {
|
|
2242
|
+
const source = str3(context.flags.source) ?? "csv";
|
|
373
2243
|
if (!IMPORT_SOURCES.includes(source)) {
|
|
374
|
-
|
|
2244
|
+
throw new UsageError(`Unknown source "${source}". One of: ${IMPORT_SOURCES.join(", ")}.`);
|
|
2245
|
+
}
|
|
2246
|
+
const file = str3(context.flags.file);
|
|
2247
|
+
if (file === undefined)
|
|
2248
|
+
throw new UsageError("Pass --file with the export to import.");
|
|
2249
|
+
const text = await context.readFile(file);
|
|
2250
|
+
let data = text;
|
|
2251
|
+
if (source !== "csv") {
|
|
2252
|
+
try {
|
|
2253
|
+
data = JSON.parse(text);
|
|
2254
|
+
} catch {
|
|
2255
|
+
throw new UsageError(`${file} is not valid JSON; a ${source} export should be.`);
|
|
2256
|
+
}
|
|
375
2257
|
}
|
|
376
|
-
if (flags.file === undefined)
|
|
377
|
-
return fail("Pass --file with the export to import.");
|
|
378
|
-
const text = await context.readFile(flags.file);
|
|
379
|
-
const data = source === "csv" ? text : JSON.parse(text);
|
|
380
2258
|
const body = { source, data };
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
if (
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
...preview.assignees.map((assignee) => ` ${assignee.value}${assignee.matched ? "" : " (no matching member)"}`)
|
|
399
|
-
];
|
|
400
|
-
if (preview.unmappedPriorities.length > 0) {
|
|
401
|
-
lines.push("", `Unrecognised priorities: ${preview.unmappedPriorities.join(", ")}`);
|
|
402
|
-
}
|
|
403
|
-
if (preview.skipped.length > 0) {
|
|
404
|
-
lines.push("", "Skipped:");
|
|
405
|
-
for (const skip of preview.skipped)
|
|
406
|
-
lines.push(` row ${skip.row}: ${skip.reason}`);
|
|
407
|
-
}
|
|
408
|
-
return ok(lines.filter((line, index) => line !== "" || index > 0).join(`
|
|
409
|
-
`));
|
|
2259
|
+
const repository = str3(context.flags.repository);
|
|
2260
|
+
if (repository !== undefined)
|
|
2261
|
+
body.repository = repository;
|
|
2262
|
+
const baseUrl = str3(context.flags["base-url"]);
|
|
2263
|
+
if (baseUrl !== undefined)
|
|
2264
|
+
body.baseUrl = baseUrl;
|
|
2265
|
+
return body;
|
|
2266
|
+
}
|
|
2267
|
+
function formatRun(run) {
|
|
2268
|
+
const lines = [
|
|
2269
|
+
`${run.id} [${run.status}] ${run.source}`,
|
|
2270
|
+
` ${run.cursor}/${run.totalItems} committed — ${run.createdCount} created, ${run.updatedCount} updated`
|
|
2271
|
+
];
|
|
2272
|
+
if (run.error !== null)
|
|
2273
|
+
lines.push(` error: ${run.error}`);
|
|
2274
|
+
for (const miss of run.unmatched.slice(0, 20)) {
|
|
2275
|
+
lines.push(` unmatched ${miss.kind} "${miss.value}" on ${miss.item}`);
|
|
410
2276
|
}
|
|
2277
|
+
if (run.unmatched.length > 20) {
|
|
2278
|
+
lines.push(` …and ${run.unmatched.length - 20} more`);
|
|
2279
|
+
}
|
|
2280
|
+
return lines.join(`
|
|
2281
|
+
`);
|
|
2282
|
+
}
|
|
2283
|
+
var previewImportHandler = async (context) => {
|
|
2284
|
+
const projectId = await context.projectId(str3(context.args.projectId));
|
|
2285
|
+
const body = await importBody(context);
|
|
2286
|
+
const preview = await (await context.client()).previewImport(projectId, body);
|
|
2287
|
+
const lines = [
|
|
2288
|
+
`${preview.total} items from ${preview.source}.`,
|
|
2289
|
+
preview.idempotent ? "" : "This source has no stable ids, so re-importing an edited row creates a second item.",
|
|
2290
|
+
"",
|
|
2291
|
+
"States:",
|
|
2292
|
+
...preview.states.map((state) => ` ${state.name} → ${state.suggestedStateId ?? "no match; will use the default state"}`),
|
|
2293
|
+
"",
|
|
2294
|
+
"Labels:",
|
|
2295
|
+
...preview.labels.map((label) => ` ${label.name}${label.exists ? "" : " (will create)"}`),
|
|
2296
|
+
"",
|
|
2297
|
+
"Assignees:",
|
|
2298
|
+
...preview.assignees.map((assignee) => ` ${assignee.value}${assignee.matched ? "" : " (no matching member)"}`)
|
|
2299
|
+
];
|
|
2300
|
+
if (preview.unmappedPriorities.length > 0) {
|
|
2301
|
+
lines.push("", `Unrecognised priorities: ${preview.unmappedPriorities.join(", ")}`);
|
|
2302
|
+
}
|
|
2303
|
+
if (preview.skipped.length > 0) {
|
|
2304
|
+
lines.push("", "Skipped:");
|
|
2305
|
+
for (const skip of preview.skipped)
|
|
2306
|
+
lines.push(` row ${skip.row}: ${skip.reason}`);
|
|
2307
|
+
}
|
|
2308
|
+
return { data: preview, text: lines.filter((line, index) => line !== "" || index > 0).join(`
|
|
2309
|
+
`) };
|
|
2310
|
+
};
|
|
2311
|
+
var runImportHandler = async (context) => {
|
|
2312
|
+
const projectId = await context.projectId(str3(context.args.projectId));
|
|
2313
|
+
const body = await importBody(context);
|
|
411
2314
|
const stateMap = {};
|
|
412
|
-
|
|
413
|
-
|
|
2315
|
+
const pairs = context.flags.map ?? [];
|
|
2316
|
+
for (const pair of pairs.flatMap((value) => value.split(","))) {
|
|
2317
|
+
const trimmed = pair.trim();
|
|
2318
|
+
if (trimmed === "")
|
|
2319
|
+
continue;
|
|
2320
|
+
const at = trimmed.indexOf("=");
|
|
414
2321
|
if (at < 0)
|
|
415
|
-
|
|
416
|
-
stateMap[
|
|
2322
|
+
throw new UsageError(`--map takes "SourceState=<stateId>", got "${trimmed}".`);
|
|
2323
|
+
stateMap[trimmed.slice(0, at)] = trimmed.slice(at + 1);
|
|
417
2324
|
}
|
|
418
2325
|
if (Object.keys(stateMap).length > 0)
|
|
419
2326
|
body.stateMap = stateMap;
|
|
420
|
-
const { runId } = await context.client.startImport(
|
|
421
|
-
return
|
|
2327
|
+
const { runId } = await (await context.client()).startImport(projectId, body, context.mutationOptions());
|
|
2328
|
+
return {
|
|
2329
|
+
data: { runId },
|
|
2330
|
+
text: `Queued as ${runId}. Follow it with: ycadence import status ${runId}`,
|
|
2331
|
+
ids: [runId]
|
|
2332
|
+
};
|
|
2333
|
+
};
|
|
2334
|
+
var importStatusHandler = async (context) => {
|
|
2335
|
+
const run = await (await context.client()).getImport(str3(context.args.runId));
|
|
2336
|
+
return { data: run, text: formatRun(run) };
|
|
2337
|
+
};
|
|
2338
|
+
var listImportsHandler = async (context) => {
|
|
2339
|
+
const projectId = await context.projectId(str3(context.args.projectId));
|
|
2340
|
+
const { runs } = await (await context.client()).listImports(projectId);
|
|
2341
|
+
return {
|
|
2342
|
+
data: runs,
|
|
2343
|
+
text: runs.length === 0 ? "No imports." : runs.map((run) => formatRun(run)).join(`
|
|
2344
|
+
|
|
2345
|
+
`),
|
|
2346
|
+
empty: "No imports."
|
|
2347
|
+
};
|
|
2348
|
+
};
|
|
2349
|
+
|
|
2350
|
+
// src/handlers/intake.ts
|
|
2351
|
+
function str4(value) {
|
|
2352
|
+
return typeof value === "string" ? value : undefined;
|
|
422
2353
|
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
2354
|
+
var TRIAGE_DECISIONS = ["accepted", "declined", "snoozed", "duplicate"];
|
|
2355
|
+
var listIntakeHandler = async (context) => {
|
|
2356
|
+
const projectId = await context.projectId();
|
|
2357
|
+
const { items } = await (await context.client()).listIntake(projectId, {
|
|
2358
|
+
...context.flags["include-decided"] === true ? { includeDecided: true } : {}
|
|
2359
|
+
});
|
|
2360
|
+
return {
|
|
2361
|
+
data: items,
|
|
2362
|
+
columns: ["id", "status", "source", "submittedAt"],
|
|
2363
|
+
empty: "The triage queue is empty."
|
|
2364
|
+
};
|
|
2365
|
+
};
|
|
2366
|
+
var submitIntakeHandler = async (context) => {
|
|
2367
|
+
const projectId = await context.projectId();
|
|
2368
|
+
const title = str4(context.flags.title);
|
|
2369
|
+
const file = str4(context.flags["description-file"]);
|
|
2370
|
+
const description = file === undefined ? str4(context.flags.description) : file === "-" ? await context.readStdin() : await context.readFile(file);
|
|
2371
|
+
const email = str4(context.flags.email);
|
|
2372
|
+
const result = await (await context.client()).submitIntake(projectId, {
|
|
2373
|
+
title,
|
|
2374
|
+
source: "api",
|
|
2375
|
+
...description === undefined ? {} : { description },
|
|
2376
|
+
...email === undefined ? {} : { sourceEmail: email }
|
|
2377
|
+
}, context.mutationOptions());
|
|
2378
|
+
return {
|
|
2379
|
+
data: result,
|
|
2380
|
+
text: `Submitted for triage as ${result.intakeItemId}.`,
|
|
2381
|
+
ids: [result.intakeItemId]
|
|
2382
|
+
};
|
|
2383
|
+
};
|
|
2384
|
+
var triageIntakeHandler = async (context) => {
|
|
2385
|
+
const client = await context.client();
|
|
2386
|
+
const intakeItemId = str4(context.args.intakeItem);
|
|
2387
|
+
if (!isUuid(intakeItemId))
|
|
2388
|
+
throw new UsageError(`"${intakeItemId}" is not an intake item id.`);
|
|
2389
|
+
const decision = str4(context.flags.decision);
|
|
2390
|
+
const body = { status: decision };
|
|
2391
|
+
if (decision === "snoozed") {
|
|
2392
|
+
const until = str4(context.flags.until);
|
|
2393
|
+
if (until === undefined || Number.isNaN(Date.parse(until))) {
|
|
2394
|
+
throw new UsageError("Snoozing needs --until <date>.");
|
|
2395
|
+
}
|
|
2396
|
+
body.snoozedUntil = until;
|
|
432
2397
|
}
|
|
433
|
-
if (
|
|
434
|
-
|
|
2398
|
+
if (decision === "duplicate") {
|
|
2399
|
+
const of = str4(context.flags["duplicate-of"]);
|
|
2400
|
+
if (of === undefined)
|
|
2401
|
+
throw new UsageError("Marking a duplicate needs --duplicate-of <work item>.");
|
|
2402
|
+
body.duplicateOfId = await resolveWorkItemId(client, of, () => context.projectId());
|
|
435
2403
|
}
|
|
436
|
-
|
|
2404
|
+
const result = await client.triageIntake(intakeItemId, body, context.mutationOptions());
|
|
2405
|
+
return { data: { intakeItemId, ...result }, text: `Triage recorded: ${result.status}.` };
|
|
2406
|
+
};
|
|
2407
|
+
|
|
2408
|
+
// src/handlers/project.ts
|
|
2409
|
+
function str5(value) {
|
|
2410
|
+
return typeof value === "string" ? value : undefined;
|
|
2411
|
+
}
|
|
2412
|
+
function legacyProject(context, command) {
|
|
2413
|
+
const positional = str5(context.args.projectId);
|
|
2414
|
+
if (positional !== undefined) {
|
|
2415
|
+
context.notice(`"ycadence ${command} <projectId>" is deprecated; use "ycadence ${command} list --project <projectId>".`);
|
|
2416
|
+
}
|
|
2417
|
+
return positional;
|
|
2418
|
+
}
|
|
2419
|
+
var listStatesHandler = async (context) => {
|
|
2420
|
+
const projectId = await context.projectId(legacyProject(context, "states"));
|
|
2421
|
+
const states = await listStates(await context.client(), projectId);
|
|
2422
|
+
return {
|
|
2423
|
+
data: states,
|
|
2424
|
+
columns: ["id", "name", "group"],
|
|
2425
|
+
empty: "No states.",
|
|
2426
|
+
text: states.length === 0 ? "No states." : states.map((state) => `${state.id} ${state.name} (${state.group})`).join(`
|
|
2427
|
+
`)
|
|
2428
|
+
};
|
|
2429
|
+
};
|
|
2430
|
+
var listLabelsHandler = async (context) => {
|
|
2431
|
+
const projectId = await context.projectId();
|
|
2432
|
+
const { labels } = await (await context.client()).listLabels(projectId);
|
|
2433
|
+
return { data: labels, columns: ["id", "name", "color"], empty: "No labels." };
|
|
2434
|
+
};
|
|
2435
|
+
var createLabelHandler = async (context) => {
|
|
2436
|
+
const projectId = await context.projectId();
|
|
2437
|
+
const name = str5(context.flags.name);
|
|
2438
|
+
const label = await (await context.client()).createLabel(projectId, {
|
|
2439
|
+
name,
|
|
2440
|
+
color: str5(context.flags.color),
|
|
2441
|
+
...str5(context.flags.description) === undefined ? {} : { description: str5(context.flags.description) }
|
|
2442
|
+
}, context.mutationOptions());
|
|
2443
|
+
return { data: label, text: `Created label ${label.name} (${label.id}).`, ids: [label.id] };
|
|
2444
|
+
};
|
|
2445
|
+
var listTypesHandler = async (context) => {
|
|
2446
|
+
const projectId = await context.projectId();
|
|
2447
|
+
const { types: types2 } = await (await context.client()).listTypes(projectId);
|
|
2448
|
+
return { data: types2, columns: ["id", "name", "level"], empty: "No work item types." };
|
|
2449
|
+
};
|
|
2450
|
+
var listCyclesHandler = async (context) => {
|
|
2451
|
+
const projectId = await context.projectId(legacyProject(context, "cycles"));
|
|
2452
|
+
const { cycles } = await (await context.client()).listCycles(projectId);
|
|
2453
|
+
return {
|
|
2454
|
+
data: cycles,
|
|
2455
|
+
columns: ["id", "name", "status", "startDate", "endDate"],
|
|
2456
|
+
empty: "No cycles.",
|
|
2457
|
+
text: cycles.length === 0 ? "No cycles." : cycles.map((cycle) => `${cycle.id} ${cycle.name} [${cycle.status}]`).join(`
|
|
2458
|
+
`)
|
|
2459
|
+
};
|
|
2460
|
+
};
|
|
2461
|
+
var showCycleHandler = async (context) => {
|
|
2462
|
+
const cycleId = str5(context.args.cycle);
|
|
2463
|
+
const progress = await (await context.client()).getCycleProgress(cycleId);
|
|
2464
|
+
return { data: { id: cycleId, ...progress } };
|
|
2465
|
+
};
|
|
2466
|
+
var createCycleHandler = async (context) => {
|
|
2467
|
+
const projectId = await context.projectId();
|
|
2468
|
+
const cycle = await (await context.client()).createCycle(projectId, {
|
|
2469
|
+
name: str5(context.flags.name),
|
|
2470
|
+
...str5(context.flags.description) === undefined ? {} : { description: str5(context.flags.description) },
|
|
2471
|
+
...str5(context.flags["start-date"]) === undefined ? {} : { startDate: str5(context.flags["start-date"]) },
|
|
2472
|
+
...str5(context.flags["end-date"]) === undefined ? {} : { endDate: str5(context.flags["end-date"]) }
|
|
2473
|
+
}, context.mutationOptions());
|
|
2474
|
+
return { data: cycle, text: `Created cycle ${cycle.name} (${cycle.id}).`, ids: [cycle.id] };
|
|
2475
|
+
};
|
|
2476
|
+
var addToCycleHandler = async (context) => {
|
|
2477
|
+
const client = await context.client();
|
|
2478
|
+
const cycleId = str5(context.args.cycle);
|
|
2479
|
+
const values = context.args.workItems ?? [];
|
|
2480
|
+
if (values.length > 200)
|
|
2481
|
+
throw new UsageError("Add at most 200 work items at a time.");
|
|
2482
|
+
const ids = [];
|
|
2483
|
+
for (const value of values) {
|
|
2484
|
+
ids.push(await resolveWorkItemId(client, value, () => context.projectId()));
|
|
2485
|
+
}
|
|
2486
|
+
const result = await client.addWorkItemsToCycle(cycleId, ids, context.mutationOptions());
|
|
2487
|
+
return {
|
|
2488
|
+
data: { cycleId, ...result },
|
|
2489
|
+
text: `Added ${result.added} work item${result.added === 1 ? "" : "s"} to the cycle.`
|
|
2490
|
+
};
|
|
2491
|
+
};
|
|
2492
|
+
var listViewsHandler = async (context) => {
|
|
2493
|
+
const projectId = await context.projectId();
|
|
2494
|
+
const { views } = await (await context.client()).listViews(projectId);
|
|
2495
|
+
return { data: views, columns: ["id", "name", "access"], empty: "No views." };
|
|
2496
|
+
};
|
|
2497
|
+
var createViewHandler = async (context) => {
|
|
2498
|
+
const projectId = await context.projectId();
|
|
2499
|
+
const rawFilter = str5(context.flags.filter);
|
|
2500
|
+
let where;
|
|
2501
|
+
if (rawFilter !== undefined) {
|
|
2502
|
+
try {
|
|
2503
|
+
where = JSON.parse(rawFilter);
|
|
2504
|
+
} catch {
|
|
2505
|
+
throw new UsageError("--filter takes a JSON filter expression.");
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
const access = str5(context.flags.access);
|
|
2509
|
+
const view = await (await context.client()).createView(projectId, {
|
|
2510
|
+
name: str5(context.flags.name),
|
|
2511
|
+
...str5(context.flags.description) === undefined ? {} : { description: str5(context.flags.description) },
|
|
2512
|
+
...where === undefined ? {} : { where },
|
|
2513
|
+
...access === undefined ? {} : { access }
|
|
2514
|
+
}, context.mutationOptions());
|
|
2515
|
+
return { data: view, text: `Saved view ${view.name} (${view.id}).`, ids: [view.id] };
|
|
2516
|
+
};
|
|
2517
|
+
|
|
2518
|
+
// src/handlers/work.ts
|
|
2519
|
+
var WORK_COLUMNS = ["key", "priority", "name"];
|
|
2520
|
+
var priorityFlag = {
|
|
2521
|
+
name: "priority",
|
|
2522
|
+
type: "string",
|
|
2523
|
+
values: PRIORITIES,
|
|
2524
|
+
placeholder: "<priority>",
|
|
2525
|
+
description: `One of ${PRIORITIES.join(", ")}`
|
|
2526
|
+
};
|
|
2527
|
+
var WORK_FIELD_FLAGS = {
|
|
2528
|
+
title: { name: "title", type: "string", placeholder: "<text>", description: "Title" },
|
|
2529
|
+
description: {
|
|
2530
|
+
name: "description",
|
|
2531
|
+
type: "string",
|
|
2532
|
+
placeholder: "<markdown>",
|
|
2533
|
+
description: "Description"
|
|
2534
|
+
},
|
|
2535
|
+
descriptionFile: {
|
|
2536
|
+
name: "description-file",
|
|
2537
|
+
type: "string",
|
|
2538
|
+
placeholder: "<path|->",
|
|
2539
|
+
description: "Read the description from a file, or - for stdin"
|
|
2540
|
+
},
|
|
2541
|
+
priority: priorityFlag,
|
|
2542
|
+
state: {
|
|
2543
|
+
name: "state",
|
|
2544
|
+
type: "string",
|
|
2545
|
+
placeholder: "<name|id>",
|
|
2546
|
+
description: "State, by name or id"
|
|
2547
|
+
},
|
|
2548
|
+
parent: {
|
|
2549
|
+
name: "parent",
|
|
2550
|
+
type: "string",
|
|
2551
|
+
placeholder: "<id>",
|
|
2552
|
+
description: "Parent work item id"
|
|
2553
|
+
},
|
|
2554
|
+
startDate: {
|
|
2555
|
+
name: "start-date",
|
|
2556
|
+
type: "string",
|
|
2557
|
+
placeholder: "<YYYY-MM-DD>",
|
|
2558
|
+
description: "Start date"
|
|
2559
|
+
},
|
|
2560
|
+
targetDate: {
|
|
2561
|
+
name: "target-date",
|
|
2562
|
+
type: "string",
|
|
2563
|
+
placeholder: "<YYYY-MM-DD>",
|
|
2564
|
+
description: "Target date"
|
|
2565
|
+
}
|
|
2566
|
+
};
|
|
2567
|
+
function str6(value) {
|
|
2568
|
+
return typeof value === "string" ? value : undefined;
|
|
2569
|
+
}
|
|
2570
|
+
function list(value) {
|
|
2571
|
+
return Array.isArray(value) ? value.map(String) : typeof value === "string" ? [value] : [];
|
|
2572
|
+
}
|
|
2573
|
+
function date(value, flag) {
|
|
2574
|
+
const text = str6(value);
|
|
2575
|
+
if (text === undefined)
|
|
2576
|
+
return;
|
|
2577
|
+
if (Number.isNaN(Date.parse(text)))
|
|
2578
|
+
throw new UsageError(`--${flag} takes a date, got "${text}".`);
|
|
2579
|
+
return text;
|
|
2580
|
+
}
|
|
2581
|
+
async function description(context) {
|
|
2582
|
+
const inline = str6(context.flags.description);
|
|
2583
|
+
const file = str6(context.flags["description-file"]);
|
|
2584
|
+
if (inline !== undefined && file !== undefined) {
|
|
2585
|
+
throw new UsageError("Pass --description or --description-file, not both.");
|
|
2586
|
+
}
|
|
2587
|
+
if (file === undefined)
|
|
2588
|
+
return inline;
|
|
2589
|
+
return file === "-" ? context.readStdin() : context.readFile(file);
|
|
2590
|
+
}
|
|
2591
|
+
function detailText(item) {
|
|
2592
|
+
return [
|
|
2593
|
+
`${item.key} ${item.name}`,
|
|
2594
|
+
`priority ${item.priority}`,
|
|
2595
|
+
`state ${item.stateId ?? "—"}`,
|
|
2596
|
+
"",
|
|
2597
|
+
item.description ?? "(no description)"
|
|
2598
|
+
].join(`
|
|
437
2599
|
`);
|
|
438
2600
|
}
|
|
439
|
-
function
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
2601
|
+
async function workItemId(context, value) {
|
|
2602
|
+
return resolveWorkItemId(await context.client(), value, () => context.projectId());
|
|
2603
|
+
}
|
|
2604
|
+
var listWork = async (context) => {
|
|
2605
|
+
const client = await context.client();
|
|
2606
|
+
const projectId = await context.projectId(str6(context.args.projectId));
|
|
2607
|
+
const conditions = [];
|
|
2608
|
+
const assignees = list(context.flags.assignee);
|
|
2609
|
+
if (assignees.length > 0)
|
|
2610
|
+
conditions.push({ field: "assignee", operator: "in", value: assignees });
|
|
2611
|
+
const labels = list(context.flags.label);
|
|
2612
|
+
if (labels.length > 0) {
|
|
2613
|
+
const known = (await client.listLabels(projectId)).labels;
|
|
2614
|
+
const ids = labels.map((value) => {
|
|
2615
|
+
const label = known.find((candidate) => candidate.id === value) ?? known.find((candidate) => candidate.name.toLowerCase() === value.toLowerCase());
|
|
2616
|
+
if (label === undefined) {
|
|
2617
|
+
throw new CliError(`No label "${value}" in this project.`, "validation_failed", EXIT.validation);
|
|
2618
|
+
}
|
|
2619
|
+
return label.id;
|
|
2620
|
+
});
|
|
2621
|
+
conditions.push({ field: "label", operator: "in", value: ids });
|
|
2622
|
+
}
|
|
2623
|
+
const rawFilter = str6(context.flags.filter);
|
|
2624
|
+
if (rawFilter !== undefined) {
|
|
2625
|
+
try {
|
|
2626
|
+
conditions.push(JSON.parse(rawFilter));
|
|
2627
|
+
} catch {
|
|
2628
|
+
throw new UsageError("--filter takes a JSON filter expression.");
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
const stateValue = str6(context.flags.state);
|
|
2632
|
+
const state = stateValue === undefined ? undefined : isUuid(stateValue) ? stateValue : findState(await listStates(client, projectId), stateValue).id;
|
|
2633
|
+
const query = {
|
|
2634
|
+
filter: conditions.length === 0 ? undefined : conditions.length === 1 ? conditions[0] : { all: conditions },
|
|
2635
|
+
state,
|
|
2636
|
+
priority: str6(context.flags.priority)
|
|
2637
|
+
};
|
|
2638
|
+
if (context.globals.all) {
|
|
2639
|
+
const items = [];
|
|
2640
|
+
for await (const item of paginate((cursor) => client.listWorkItems(projectId, { ...query, limit: context.globals.limit ?? 200, cursor }), { cursor: context.globals.cursor })) {
|
|
2641
|
+
items.push(item);
|
|
2642
|
+
}
|
|
2643
|
+
return { data: items, nextCursor: null, columns: WORK_COLUMNS, empty: "No work items." };
|
|
2644
|
+
}
|
|
2645
|
+
const page = await client.listWorkItems(projectId, {
|
|
2646
|
+
...query,
|
|
2647
|
+
limit: context.globals.limit ?? 50,
|
|
2648
|
+
cursor: context.globals.cursor
|
|
2649
|
+
});
|
|
2650
|
+
return {
|
|
2651
|
+
data: page.items,
|
|
2652
|
+
nextCursor: page.nextCursor,
|
|
2653
|
+
columns: WORK_COLUMNS,
|
|
2654
|
+
empty: "No work items."
|
|
2655
|
+
};
|
|
2656
|
+
};
|
|
2657
|
+
var getWork = async (context) => {
|
|
2658
|
+
const id = await workItemId(context, str6(context.args.workItem));
|
|
2659
|
+
const item = await (await context.client()).getWorkItem(id);
|
|
2660
|
+
return { data: item, text: detailText(item) };
|
|
2661
|
+
};
|
|
2662
|
+
var createWork = async (context) => {
|
|
2663
|
+
const title = str6(context.flags.title);
|
|
2664
|
+
if (title === undefined || title.trim() === "") {
|
|
2665
|
+
throw new UsageError("Pass --title with the work item title.");
|
|
2666
|
+
}
|
|
2667
|
+
const projectId = await context.projectId(str6(context.args.projectId));
|
|
2668
|
+
const client = await context.client();
|
|
2669
|
+
const body = { name: title };
|
|
2670
|
+
const text = await description(context);
|
|
2671
|
+
if (text !== undefined)
|
|
2672
|
+
body.description = text;
|
|
2673
|
+
const priority = str6(context.flags.priority);
|
|
2674
|
+
if (priority !== undefined)
|
|
2675
|
+
body.priority = priority;
|
|
2676
|
+
const stateValue = str6(context.flags.state);
|
|
2677
|
+
if (stateValue !== undefined) {
|
|
2678
|
+
body.stateId = isUuid(stateValue) ? stateValue : findState(await listStates(client, projectId), stateValue).id;
|
|
2679
|
+
}
|
|
2680
|
+
const assignees = list(context.flags.assignee);
|
|
2681
|
+
for (const assignee of assignees) {
|
|
2682
|
+
if (!isUuid(assignee)) {
|
|
2683
|
+
throw new UsageError(`--assignee takes a user id; "${assignee}" is not one.`);
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
if (assignees.length > 0)
|
|
2687
|
+
body.assigneeIds = assignees;
|
|
2688
|
+
const labels = list(context.flags.label);
|
|
2689
|
+
if (labels.length > 0) {
|
|
2690
|
+
const known = (await client.listLabels(projectId)).labels;
|
|
2691
|
+
body.labelIds = labels.map((value) => {
|
|
2692
|
+
const label = known.find((candidate) => candidate.id === value) ?? known.find((candidate) => candidate.name.toLowerCase() === value.toLowerCase());
|
|
2693
|
+
if (label === undefined) {
|
|
2694
|
+
throw new CliError(`No label "${value}" in this project.`, "validation_failed", EXIT.validation);
|
|
2695
|
+
}
|
|
2696
|
+
return label.id;
|
|
2697
|
+
});
|
|
2698
|
+
}
|
|
2699
|
+
const parent = str6(context.flags.parent);
|
|
2700
|
+
if (parent !== undefined)
|
|
2701
|
+
body.parentId = await workItemId(context, parent);
|
|
2702
|
+
const startDate = date(context.flags["start-date"], "start-date");
|
|
2703
|
+
if (startDate !== undefined)
|
|
2704
|
+
body.startDate = startDate;
|
|
2705
|
+
const targetDate = date(context.flags["target-date"], "target-date");
|
|
2706
|
+
if (targetDate !== undefined)
|
|
2707
|
+
body.targetDate = targetDate;
|
|
2708
|
+
const created = await client.createWorkItem(projectId, body, context.mutationOptions());
|
|
2709
|
+
return { data: created, text: `${created.key} ${created.name}`, ids: [created.key] };
|
|
2710
|
+
};
|
|
2711
|
+
var updateWork = async (context) => {
|
|
2712
|
+
const client = await context.client();
|
|
2713
|
+
const id = await workItemId(context, str6(context.args.workItem));
|
|
2714
|
+
const body = {};
|
|
2715
|
+
const title = str6(context.flags.title);
|
|
2716
|
+
if (title !== undefined)
|
|
2717
|
+
body.name = title;
|
|
2718
|
+
const text = await description(context);
|
|
2719
|
+
if (text !== undefined)
|
|
2720
|
+
body.description = text;
|
|
2721
|
+
const priority = str6(context.flags.priority);
|
|
2722
|
+
if (priority !== undefined)
|
|
2723
|
+
body.priority = priority;
|
|
2724
|
+
const stateValue = str6(context.flags.state);
|
|
2725
|
+
if (stateValue !== undefined) {
|
|
2726
|
+
body.stateId = isUuid(stateValue) ? stateValue : findState(await listStates(client, await context.projectId()), stateValue).id;
|
|
2727
|
+
}
|
|
2728
|
+
if (context.flags["no-parent"] === true)
|
|
2729
|
+
body.parentId = null;
|
|
2730
|
+
const parent = str6(context.flags.parent);
|
|
2731
|
+
if (parent !== undefined)
|
|
2732
|
+
body.parentId = await workItemId(context, parent);
|
|
2733
|
+
const startDate = date(context.flags["start-date"], "start-date");
|
|
2734
|
+
if (startDate !== undefined)
|
|
2735
|
+
body.startDate = startDate;
|
|
2736
|
+
const targetDate = date(context.flags["target-date"], "target-date");
|
|
2737
|
+
if (targetDate !== undefined)
|
|
2738
|
+
body.targetDate = targetDate;
|
|
2739
|
+
if (Object.keys(body).length === 0) {
|
|
2740
|
+
throw new UsageError("Nothing to update. Pass at least one of --title, --description, --priority, --state, --parent, --start-date, --target-date.");
|
|
2741
|
+
}
|
|
2742
|
+
const version = context.flags["expect-version"];
|
|
2743
|
+
const updated = await client.updateWorkItem(id, body, context.mutationOptions(version === undefined ? {} : { expectedVersion: version }));
|
|
2744
|
+
return { data: updated, text: `Updated ${updated.key}.`, ids: [updated.key] };
|
|
2745
|
+
};
|
|
2746
|
+
async function moveTo(context, value, choose, verb) {
|
|
2747
|
+
const client = await context.client();
|
|
2748
|
+
const id = await workItemId(context, value);
|
|
2749
|
+
const item = await client.getWorkItem(id);
|
|
2750
|
+
const projectId = projectOf(item) ?? await context.projectId();
|
|
2751
|
+
const target = choose(await listStates(client, projectId));
|
|
2752
|
+
if (item.stateId === target.id) {
|
|
2753
|
+
return {
|
|
2754
|
+
data: { ...item, changed: false },
|
|
2755
|
+
text: `${item.key} is already in ${target.name}.`,
|
|
2756
|
+
ids: [item.key]
|
|
2757
|
+
};
|
|
2758
|
+
}
|
|
2759
|
+
const updated = await client.updateWorkItem(id, { stateId: target.id }, context.mutationOptions(item.version === undefined ? {} : { expectedVersion: item.version }));
|
|
2760
|
+
return {
|
|
2761
|
+
data: { ...updated, changed: true },
|
|
2762
|
+
text: verb === "close" ? `Closed ${updated.key}.` : `Moved ${updated.key} to ${target.name}.`,
|
|
2763
|
+
ids: [updated.key]
|
|
2764
|
+
};
|
|
443
2765
|
}
|
|
2766
|
+
var closeWork = (context) => moveTo(context, str6(context.args.workItem), closingState, "close");
|
|
2767
|
+
var transitionWork = (context) => {
|
|
2768
|
+
const to = str6(context.flags.to);
|
|
2769
|
+
if (to === undefined)
|
|
2770
|
+
throw new UsageError("Pass --to with the state name or id.");
|
|
2771
|
+
return moveTo(context, str6(context.args.workItem), (states) => findState(states, to), "transition");
|
|
2772
|
+
};
|
|
2773
|
+
var deleteWork = async (context) => {
|
|
2774
|
+
const value = str6(context.args.workItem);
|
|
2775
|
+
const id = await workItemId(context, value);
|
|
2776
|
+
if (!await context.confirm(`Delete work item ${value}? This cannot be undone.`)) {
|
|
2777
|
+
throw new CliError("Cancelled.", "cancelled", EXIT.failure);
|
|
2778
|
+
}
|
|
2779
|
+
await (await context.client()).deleteWorkItem(id, context.mutationOptions());
|
|
2780
|
+
return { data: { id, deleted: true }, text: `Deleted ${value}.`, ids: [id] };
|
|
2781
|
+
};
|
|
444
2782
|
|
|
445
|
-
// src/
|
|
446
|
-
var
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
2783
|
+
// src/tree.ts
|
|
2784
|
+
var workItemArg = {
|
|
2785
|
+
name: "workItem",
|
|
2786
|
+
required: true,
|
|
2787
|
+
description: "Work item id, or key such as DP-42 (a key needs --project)"
|
|
2788
|
+
};
|
|
2789
|
+
var importFlags = [
|
|
2790
|
+
{
|
|
2791
|
+
name: "source",
|
|
2792
|
+
type: "string",
|
|
2793
|
+
placeholder: "<csv|github|jira|linear>",
|
|
2794
|
+
description: "What the file was exported from (default csv)"
|
|
2795
|
+
},
|
|
2796
|
+
{ name: "file", type: "string", placeholder: "<path>", description: "The export to import" },
|
|
2797
|
+
{
|
|
2798
|
+
name: "repository",
|
|
2799
|
+
type: "string",
|
|
2800
|
+
placeholder: "<org/repo>",
|
|
2801
|
+
description: "GitHub repository the export came from"
|
|
2802
|
+
},
|
|
2803
|
+
{
|
|
2804
|
+
name: "base-url",
|
|
2805
|
+
type: "string",
|
|
2806
|
+
placeholder: "<url>",
|
|
2807
|
+
description: "Jira site the export came from"
|
|
2808
|
+
}
|
|
2809
|
+
];
|
|
2810
|
+
var legacyProjectArg = {
|
|
2811
|
+
name: "projectId",
|
|
2812
|
+
description: "Project id (the older positional form of --project)"
|
|
2813
|
+
};
|
|
2814
|
+
var LOGIN_FLAGS = [
|
|
2815
|
+
{ name: "url", type: "string", placeholder: "<url>", description: "The instance URL" },
|
|
2816
|
+
{ name: "api-key-stdin", type: "boolean", description: "Read the API key from stdin" },
|
|
2817
|
+
{ name: "device", type: "boolean", description: "Use the device flow (no local browser)" },
|
|
2818
|
+
{
|
|
2819
|
+
name: "no-browser",
|
|
2820
|
+
type: "boolean",
|
|
2821
|
+
description: "Print the sign-in URL instead of opening it"
|
|
2822
|
+
},
|
|
2823
|
+
{
|
|
2824
|
+
name: "oauth-client-id",
|
|
2825
|
+
type: "string",
|
|
2826
|
+
placeholder: "<id>",
|
|
2827
|
+
description: "Desktop OAuth client id (saved to the profile)"
|
|
2828
|
+
},
|
|
2829
|
+
{
|
|
2830
|
+
name: "iap-audience",
|
|
2831
|
+
type: "string",
|
|
2832
|
+
placeholder: "<id>",
|
|
2833
|
+
description: "IAP OAuth client id (saved to the profile)"
|
|
2834
|
+
},
|
|
2835
|
+
{ name: "skip-oauth", type: "boolean", description: "Do not sign in for IAP" }
|
|
2836
|
+
];
|
|
2837
|
+
var help = async (context) => {
|
|
2838
|
+
const words = context.args.command ?? [];
|
|
2839
|
+
let node = context.root;
|
|
2840
|
+
const path = [];
|
|
2841
|
+
for (const word of words) {
|
|
2842
|
+
const child = findChild(node, word);
|
|
2843
|
+
if (child === undefined)
|
|
2844
|
+
throw new UsageError(`Unknown command: ${words.join(" ")}.`);
|
|
2845
|
+
node = child;
|
|
2846
|
+
path.push(child.name);
|
|
2847
|
+
}
|
|
2848
|
+
return {
|
|
2849
|
+
data: { help: renderHelp(context.root, path, node) },
|
|
2850
|
+
text: renderHelp(context.root, path, node)
|
|
2851
|
+
};
|
|
2852
|
+
};
|
|
2853
|
+
var completion = async (context) => {
|
|
2854
|
+
const shell = context.args.shell;
|
|
2855
|
+
const script = completionScript(context.root, shell);
|
|
2856
|
+
return { data: { shell, script }, text: script.trimEnd() };
|
|
2857
|
+
};
|
|
2858
|
+
var version = async () => ({
|
|
2859
|
+
data: { version: CLI_VERSION },
|
|
2860
|
+
text: `ycadence ${CLI_VERSION}`
|
|
2861
|
+
});
|
|
2862
|
+
var TREE = {
|
|
2863
|
+
name: "ycadence",
|
|
2864
|
+
summary: "Project and work management from the command line",
|
|
2865
|
+
children: [
|
|
2866
|
+
{
|
|
2867
|
+
name: "work",
|
|
2868
|
+
summary: "Work items: list, read, create, update, close, move",
|
|
2869
|
+
children: [
|
|
2870
|
+
{
|
|
2871
|
+
name: "list",
|
|
2872
|
+
aliases: ["ls"],
|
|
2873
|
+
summary: "List work items in a project",
|
|
2874
|
+
flags: [
|
|
2875
|
+
{
|
|
2876
|
+
name: "state",
|
|
2877
|
+
type: "string",
|
|
2878
|
+
placeholder: "<name|id>",
|
|
2879
|
+
description: "Only items in this state"
|
|
2880
|
+
},
|
|
2881
|
+
WORK_FIELD_FLAGS.priority,
|
|
2882
|
+
{
|
|
2883
|
+
name: "assignee",
|
|
2884
|
+
type: "string",
|
|
2885
|
+
multiple: true,
|
|
2886
|
+
placeholder: "<user id>",
|
|
2887
|
+
description: "Only items assigned to this user"
|
|
2888
|
+
},
|
|
2889
|
+
{
|
|
2890
|
+
name: "label",
|
|
2891
|
+
type: "string",
|
|
2892
|
+
multiple: true,
|
|
2893
|
+
placeholder: "<name|id>",
|
|
2894
|
+
description: "Only items with this label"
|
|
2895
|
+
},
|
|
2896
|
+
{
|
|
2897
|
+
name: "filter",
|
|
2898
|
+
type: "string",
|
|
2899
|
+
placeholder: "<json>",
|
|
2900
|
+
description: "A filter expression in the query language, passed through"
|
|
2901
|
+
}
|
|
2902
|
+
],
|
|
2903
|
+
examples: [
|
|
2904
|
+
"ycadence work list --project <projectId>",
|
|
2905
|
+
'ycadence work list --state "In Progress" --priority high --format json',
|
|
2906
|
+
"ycadence work list --all --quiet"
|
|
2907
|
+
],
|
|
2908
|
+
run: listWork
|
|
2909
|
+
},
|
|
2910
|
+
{
|
|
2911
|
+
name: "get",
|
|
2912
|
+
aliases: ["show"],
|
|
2913
|
+
summary: "Read one work item",
|
|
2914
|
+
args: [workItemArg],
|
|
2915
|
+
run: getWork
|
|
2916
|
+
},
|
|
2917
|
+
{
|
|
2918
|
+
name: "create",
|
|
2919
|
+
summary: "Create a work item",
|
|
2920
|
+
mutates: true,
|
|
2921
|
+
flags: [
|
|
2922
|
+
{ ...WORK_FIELD_FLAGS.title, required: true },
|
|
2923
|
+
WORK_FIELD_FLAGS.description,
|
|
2924
|
+
WORK_FIELD_FLAGS.descriptionFile,
|
|
2925
|
+
WORK_FIELD_FLAGS.priority,
|
|
2926
|
+
WORK_FIELD_FLAGS.state,
|
|
2927
|
+
{
|
|
2928
|
+
name: "assignee",
|
|
2929
|
+
type: "string",
|
|
2930
|
+
multiple: true,
|
|
2931
|
+
placeholder: "<user id>",
|
|
2932
|
+
description: "Assign a user, by id"
|
|
2933
|
+
},
|
|
2934
|
+
{
|
|
2935
|
+
name: "label",
|
|
2936
|
+
type: "string",
|
|
2937
|
+
multiple: true,
|
|
2938
|
+
placeholder: "<name|id>",
|
|
2939
|
+
description: "Add a label, by name or id"
|
|
2940
|
+
},
|
|
2941
|
+
WORK_FIELD_FLAGS.parent,
|
|
2942
|
+
WORK_FIELD_FLAGS.startDate,
|
|
2943
|
+
WORK_FIELD_FLAGS.targetDate
|
|
2944
|
+
],
|
|
2945
|
+
examples: [
|
|
2946
|
+
'ycadence work create --project <projectId> --title "Fix login redirect" --priority high',
|
|
2947
|
+
'ycadence work create --title "Spike" --description-file notes.md --idempotency-key spike-1'
|
|
2948
|
+
],
|
|
2949
|
+
run: createWork
|
|
2950
|
+
},
|
|
2951
|
+
{
|
|
2952
|
+
name: "update",
|
|
2953
|
+
summary: "Change a work item",
|
|
2954
|
+
mutates: true,
|
|
2955
|
+
args: [workItemArg],
|
|
2956
|
+
flags: [
|
|
2957
|
+
WORK_FIELD_FLAGS.title,
|
|
2958
|
+
WORK_FIELD_FLAGS.description,
|
|
2959
|
+
WORK_FIELD_FLAGS.descriptionFile,
|
|
2960
|
+
WORK_FIELD_FLAGS.priority,
|
|
2961
|
+
WORK_FIELD_FLAGS.state,
|
|
2962
|
+
WORK_FIELD_FLAGS.parent,
|
|
2963
|
+
{ name: "no-parent", type: "boolean", description: "Remove the parent" },
|
|
2964
|
+
WORK_FIELD_FLAGS.startDate,
|
|
2965
|
+
WORK_FIELD_FLAGS.targetDate,
|
|
2966
|
+
{
|
|
2967
|
+
name: "expect-version",
|
|
2968
|
+
type: "number",
|
|
2969
|
+
placeholder: "<n>",
|
|
2970
|
+
description: "Fail if the item changed since this version (sent as If-Match)"
|
|
2971
|
+
}
|
|
2972
|
+
],
|
|
2973
|
+
run: updateWork
|
|
2974
|
+
},
|
|
2975
|
+
{
|
|
2976
|
+
name: "close",
|
|
2977
|
+
summary: "Move a work item to the project's first completed state",
|
|
2978
|
+
description: "The project comes from the work item; --project is only needed against a server older than Slice 2A.",
|
|
2979
|
+
mutates: true,
|
|
2980
|
+
args: [workItemArg],
|
|
2981
|
+
run: closeWork
|
|
2982
|
+
},
|
|
2983
|
+
{
|
|
2984
|
+
name: "transition",
|
|
2985
|
+
aliases: ["move"],
|
|
2986
|
+
summary: "Move a work item to a state",
|
|
2987
|
+
mutates: true,
|
|
2988
|
+
args: [workItemArg],
|
|
2989
|
+
flags: [
|
|
2990
|
+
{
|
|
2991
|
+
name: "to",
|
|
2992
|
+
type: "string",
|
|
2993
|
+
required: true,
|
|
2994
|
+
placeholder: "<state name|id>",
|
|
2995
|
+
description: "The state to move to"
|
|
2996
|
+
}
|
|
2997
|
+
],
|
|
2998
|
+
examples: ['ycadence work transition DP-42 --to "In Review" --project <projectId>'],
|
|
2999
|
+
run: transitionWork
|
|
3000
|
+
},
|
|
3001
|
+
{
|
|
3002
|
+
name: "delete",
|
|
3003
|
+
summary: "Delete a work item",
|
|
3004
|
+
mutates: true,
|
|
3005
|
+
destructive: true,
|
|
3006
|
+
args: [workItemArg],
|
|
3007
|
+
run: deleteWork
|
|
3008
|
+
}
|
|
3009
|
+
]
|
|
3010
|
+
},
|
|
3011
|
+
{
|
|
3012
|
+
name: "states",
|
|
3013
|
+
aliases: ["state"],
|
|
3014
|
+
summary: "A project's workflow states",
|
|
3015
|
+
args: [legacyProjectArg],
|
|
3016
|
+
run: listStatesHandler,
|
|
3017
|
+
children: [
|
|
3018
|
+
{
|
|
3019
|
+
name: "list",
|
|
3020
|
+
aliases: ["ls"],
|
|
3021
|
+
summary: "List a project's states, in order",
|
|
3022
|
+
run: listStatesHandler
|
|
3023
|
+
}
|
|
3024
|
+
]
|
|
3025
|
+
},
|
|
3026
|
+
{
|
|
3027
|
+
name: "labels",
|
|
3028
|
+
aliases: ["label"],
|
|
3029
|
+
summary: "A project's labels",
|
|
3030
|
+
children: [
|
|
3031
|
+
{
|
|
3032
|
+
name: "list",
|
|
3033
|
+
aliases: ["ls"],
|
|
3034
|
+
summary: "List a project's labels",
|
|
3035
|
+
run: listLabelsHandler
|
|
3036
|
+
},
|
|
3037
|
+
{
|
|
3038
|
+
name: "create",
|
|
3039
|
+
summary: "Create a label",
|
|
3040
|
+
mutates: true,
|
|
3041
|
+
flags: [
|
|
3042
|
+
{
|
|
3043
|
+
name: "name",
|
|
3044
|
+
type: "string",
|
|
3045
|
+
required: true,
|
|
3046
|
+
placeholder: "<name>",
|
|
3047
|
+
description: "Label name"
|
|
3048
|
+
},
|
|
3049
|
+
{
|
|
3050
|
+
name: "color",
|
|
3051
|
+
type: "string",
|
|
3052
|
+
required: true,
|
|
3053
|
+
placeholder: "<token|css colour>",
|
|
3054
|
+
description: "A theme token or CSS colour"
|
|
3055
|
+
},
|
|
3056
|
+
{
|
|
3057
|
+
name: "description",
|
|
3058
|
+
type: "string",
|
|
3059
|
+
placeholder: "<text>",
|
|
3060
|
+
description: "Description"
|
|
3061
|
+
}
|
|
3062
|
+
],
|
|
3063
|
+
run: createLabelHandler
|
|
3064
|
+
}
|
|
3065
|
+
]
|
|
3066
|
+
},
|
|
3067
|
+
{
|
|
3068
|
+
name: "cycles",
|
|
3069
|
+
aliases: ["cycle", "sprint"],
|
|
3070
|
+
summary: "A project's cycles (sprints)",
|
|
3071
|
+
args: [legacyProjectArg],
|
|
3072
|
+
run: listCyclesHandler,
|
|
3073
|
+
children: [
|
|
3074
|
+
{
|
|
3075
|
+
name: "list",
|
|
3076
|
+
aliases: ["ls"],
|
|
3077
|
+
summary: "List a project's cycles",
|
|
3078
|
+
run: listCyclesHandler
|
|
3079
|
+
},
|
|
3080
|
+
{
|
|
3081
|
+
name: "show",
|
|
3082
|
+
summary: "Progress and burndown for a cycle",
|
|
3083
|
+
args: [{ name: "cycle", required: true, description: "Cycle id" }],
|
|
3084
|
+
run: showCycleHandler
|
|
3085
|
+
},
|
|
3086
|
+
{
|
|
3087
|
+
name: "create",
|
|
3088
|
+
summary: "Create a cycle",
|
|
3089
|
+
mutates: true,
|
|
3090
|
+
flags: [
|
|
3091
|
+
{
|
|
3092
|
+
name: "name",
|
|
3093
|
+
type: "string",
|
|
3094
|
+
required: true,
|
|
3095
|
+
placeholder: "<name>",
|
|
3096
|
+
description: "Cycle name"
|
|
3097
|
+
},
|
|
3098
|
+
{
|
|
3099
|
+
name: "description",
|
|
3100
|
+
type: "string",
|
|
3101
|
+
placeholder: "<text>",
|
|
3102
|
+
description: "Description"
|
|
3103
|
+
},
|
|
3104
|
+
WORK_FIELD_FLAGS.startDate,
|
|
3105
|
+
{
|
|
3106
|
+
name: "end-date",
|
|
3107
|
+
type: "string",
|
|
3108
|
+
placeholder: "<YYYY-MM-DD>",
|
|
3109
|
+
description: "End date"
|
|
3110
|
+
}
|
|
3111
|
+
],
|
|
3112
|
+
run: createCycleHandler
|
|
3113
|
+
},
|
|
3114
|
+
{
|
|
3115
|
+
name: "add",
|
|
3116
|
+
summary: "Add work items to a cycle, moving them out of any other",
|
|
3117
|
+
mutates: true,
|
|
3118
|
+
args: [
|
|
3119
|
+
{ name: "cycle", required: true, description: "Cycle id" },
|
|
3120
|
+
{
|
|
3121
|
+
name: "workItems",
|
|
3122
|
+
required: true,
|
|
3123
|
+
variadic: true,
|
|
3124
|
+
description: "Work item ids or keys"
|
|
3125
|
+
}
|
|
3126
|
+
],
|
|
3127
|
+
run: addToCycleHandler
|
|
3128
|
+
}
|
|
3129
|
+
]
|
|
3130
|
+
},
|
|
3131
|
+
{
|
|
3132
|
+
name: "views",
|
|
3133
|
+
aliases: ["view"],
|
|
3134
|
+
summary: "Saved views",
|
|
3135
|
+
children: [
|
|
3136
|
+
{
|
|
3137
|
+
name: "list",
|
|
3138
|
+
aliases: ["ls"],
|
|
3139
|
+
summary: "List the views you can see",
|
|
3140
|
+
run: listViewsHandler
|
|
3141
|
+
},
|
|
3142
|
+
{
|
|
3143
|
+
name: "create",
|
|
3144
|
+
summary: "Save a view",
|
|
3145
|
+
mutates: true,
|
|
3146
|
+
flags: [
|
|
3147
|
+
{
|
|
3148
|
+
name: "name",
|
|
3149
|
+
type: "string",
|
|
3150
|
+
required: true,
|
|
3151
|
+
placeholder: "<name>",
|
|
3152
|
+
description: "View name"
|
|
3153
|
+
},
|
|
3154
|
+
{
|
|
3155
|
+
name: "description",
|
|
3156
|
+
type: "string",
|
|
3157
|
+
placeholder: "<text>",
|
|
3158
|
+
description: "Description"
|
|
3159
|
+
},
|
|
3160
|
+
{
|
|
3161
|
+
name: "filter",
|
|
3162
|
+
type: "string",
|
|
3163
|
+
placeholder: "<json>",
|
|
3164
|
+
description: "Filter expression"
|
|
3165
|
+
},
|
|
3166
|
+
{
|
|
3167
|
+
name: "access",
|
|
3168
|
+
type: "string",
|
|
3169
|
+
values: ["private", "public"],
|
|
3170
|
+
placeholder: "<private|public>",
|
|
3171
|
+
description: "Who can see it (default private)"
|
|
3172
|
+
}
|
|
3173
|
+
],
|
|
3174
|
+
run: createViewHandler
|
|
3175
|
+
}
|
|
3176
|
+
]
|
|
3177
|
+
},
|
|
3178
|
+
{
|
|
3179
|
+
name: "types",
|
|
3180
|
+
aliases: ["type"],
|
|
3181
|
+
summary: "Work item types",
|
|
3182
|
+
children: [
|
|
3183
|
+
{
|
|
3184
|
+
name: "list",
|
|
3185
|
+
aliases: ["ls"],
|
|
3186
|
+
summary: "Work item types and their properties",
|
|
3187
|
+
run: listTypesHandler
|
|
3188
|
+
}
|
|
3189
|
+
]
|
|
3190
|
+
},
|
|
3191
|
+
{
|
|
3192
|
+
name: "intake",
|
|
3193
|
+
summary: "The triage queue",
|
|
3194
|
+
children: [
|
|
3195
|
+
{
|
|
3196
|
+
name: "list",
|
|
3197
|
+
aliases: ["ls"],
|
|
3198
|
+
summary: "Pending submissions, and snoozed ones that are due",
|
|
3199
|
+
flags: [
|
|
3200
|
+
{
|
|
3201
|
+
name: "include-decided",
|
|
3202
|
+
type: "boolean",
|
|
3203
|
+
description: "Include decided submissions"
|
|
3204
|
+
}
|
|
3205
|
+
],
|
|
3206
|
+
run: listIntakeHandler
|
|
3207
|
+
},
|
|
3208
|
+
{
|
|
3209
|
+
name: "submit",
|
|
3210
|
+
summary: "Submit a request for triage",
|
|
3211
|
+
mutates: true,
|
|
3212
|
+
flags: [
|
|
3213
|
+
{ ...WORK_FIELD_FLAGS.title, required: true },
|
|
3214
|
+
WORK_FIELD_FLAGS.description,
|
|
3215
|
+
WORK_FIELD_FLAGS.descriptionFile,
|
|
3216
|
+
{
|
|
3217
|
+
name: "email",
|
|
3218
|
+
type: "string",
|
|
3219
|
+
placeholder: "<email>",
|
|
3220
|
+
description: "Requester email"
|
|
3221
|
+
}
|
|
3222
|
+
],
|
|
3223
|
+
run: submitIntakeHandler
|
|
3224
|
+
},
|
|
3225
|
+
{
|
|
3226
|
+
name: "triage",
|
|
3227
|
+
summary: "Accept, decline, snooze or mark a submission as a duplicate",
|
|
3228
|
+
mutates: true,
|
|
3229
|
+
args: [{ name: "intakeItem", required: true, description: "Intake item id" }],
|
|
3230
|
+
flags: [
|
|
3231
|
+
{
|
|
3232
|
+
name: "decision",
|
|
3233
|
+
type: "string",
|
|
3234
|
+
required: true,
|
|
3235
|
+
values: TRIAGE_DECISIONS,
|
|
3236
|
+
placeholder: "<decision>",
|
|
3237
|
+
description: `One of ${TRIAGE_DECISIONS.join(", ")}`
|
|
3238
|
+
},
|
|
3239
|
+
{
|
|
3240
|
+
name: "until",
|
|
3241
|
+
type: "string",
|
|
3242
|
+
placeholder: "<date>",
|
|
3243
|
+
description: "Snooze until (with snoozed)"
|
|
3244
|
+
},
|
|
3245
|
+
{
|
|
3246
|
+
name: "duplicate-of",
|
|
3247
|
+
type: "string",
|
|
3248
|
+
placeholder: "<work item>",
|
|
3249
|
+
description: "The item it duplicates (with duplicate)"
|
|
3250
|
+
}
|
|
3251
|
+
],
|
|
3252
|
+
run: triageIntakeHandler
|
|
3253
|
+
}
|
|
3254
|
+
]
|
|
3255
|
+
},
|
|
3256
|
+
{
|
|
3257
|
+
name: "import",
|
|
3258
|
+
summary: "Import from CSV, GitHub, Jira or Linear exports",
|
|
3259
|
+
description: "`preview` and `run` are separate commands, so the safe one is the one you can type by accident.",
|
|
3260
|
+
children: [
|
|
3261
|
+
{
|
|
3262
|
+
name: "preview",
|
|
3263
|
+
summary: "Show what an import would do, without importing",
|
|
3264
|
+
args: [legacyProjectArg],
|
|
3265
|
+
flags: importFlags,
|
|
3266
|
+
examples: ["ycadence import preview --project <projectId> --file issues.csv"],
|
|
3267
|
+
run: previewImportHandler
|
|
3268
|
+
},
|
|
3269
|
+
{
|
|
3270
|
+
name: "run",
|
|
3271
|
+
summary: "Start an import",
|
|
3272
|
+
mutates: true,
|
|
3273
|
+
args: [legacyProjectArg],
|
|
3274
|
+
flags: [
|
|
3275
|
+
...importFlags,
|
|
3276
|
+
{
|
|
3277
|
+
name: "map",
|
|
3278
|
+
type: "string",
|
|
3279
|
+
multiple: true,
|
|
3280
|
+
placeholder: "<State=stateId>",
|
|
3281
|
+
description: "Map a source state to a state id"
|
|
3282
|
+
}
|
|
3283
|
+
],
|
|
3284
|
+
run: runImportHandler
|
|
3285
|
+
},
|
|
3286
|
+
{
|
|
3287
|
+
name: "status",
|
|
3288
|
+
summary: "Where an import got to",
|
|
3289
|
+
args: [{ name: "runId", required: true, description: "The run id `import run` printed" }],
|
|
3290
|
+
run: importStatusHandler
|
|
3291
|
+
},
|
|
3292
|
+
{
|
|
3293
|
+
name: "list",
|
|
3294
|
+
aliases: ["ls"],
|
|
3295
|
+
summary: "A project's imports",
|
|
3296
|
+
args: [legacyProjectArg],
|
|
3297
|
+
run: listImportsHandler
|
|
3298
|
+
}
|
|
3299
|
+
]
|
|
3300
|
+
},
|
|
3301
|
+
{
|
|
3302
|
+
name: "login",
|
|
3303
|
+
summary: "Save a profile, sign in for IAP, and store an API key",
|
|
3304
|
+
description: "Signs in with Google (loopback, or --device) against the configured Desktop OAuth client for an IAP identity token, when one is configured. The API key is read from stdin and kept in the OS keychain.",
|
|
3305
|
+
needsClient: false,
|
|
3306
|
+
flags: LOGIN_FLAGS,
|
|
3307
|
+
examples: [
|
|
3308
|
+
"ycadence login --url https://ycadence.example.com --api-key-stdin < key.txt",
|
|
3309
|
+
"ycadence login --profile yarlis --oauth-client-id <desktop client id> --iap-audience <iap client id>"
|
|
3310
|
+
],
|
|
3311
|
+
run: loginHandler
|
|
3312
|
+
},
|
|
3313
|
+
{
|
|
3314
|
+
name: "auth",
|
|
3315
|
+
summary: "Credentials for the current profile",
|
|
3316
|
+
children: [
|
|
3317
|
+
{
|
|
3318
|
+
name: "login",
|
|
3319
|
+
summary: "Same as `ycadence login`",
|
|
3320
|
+
needsClient: false,
|
|
3321
|
+
flags: LOGIN_FLAGS,
|
|
3322
|
+
run: loginHandler
|
|
3323
|
+
},
|
|
3324
|
+
{
|
|
3325
|
+
name: "status",
|
|
3326
|
+
summary: "Who the stored credential is",
|
|
3327
|
+
run: statusHandler,
|
|
3328
|
+
needsClient: false
|
|
3329
|
+
},
|
|
3330
|
+
{
|
|
3331
|
+
name: "logout",
|
|
3332
|
+
summary: "Remove the profile's stored credentials",
|
|
3333
|
+
run: logoutHandler,
|
|
3334
|
+
needsClient: false
|
|
3335
|
+
},
|
|
3336
|
+
{
|
|
3337
|
+
name: "token",
|
|
3338
|
+
summary: "Print a fresh IAP identity token, for other tools",
|
|
3339
|
+
needsClient: false,
|
|
3340
|
+
flags: [
|
|
3341
|
+
{
|
|
3342
|
+
name: "identity",
|
|
3343
|
+
type: "boolean",
|
|
3344
|
+
description: "The IAP identity token (the default)"
|
|
3345
|
+
}
|
|
3346
|
+
],
|
|
3347
|
+
run: tokenHandler
|
|
3348
|
+
}
|
|
3349
|
+
]
|
|
3350
|
+
},
|
|
3351
|
+
{
|
|
3352
|
+
name: "config",
|
|
3353
|
+
summary: "Profiles and settings",
|
|
3354
|
+
children: [
|
|
3355
|
+
{
|
|
3356
|
+
name: "list",
|
|
3357
|
+
aliases: ["ls"],
|
|
3358
|
+
summary: "Settings for the current profile, and where each comes from",
|
|
3359
|
+
run: configListHandler,
|
|
3360
|
+
needsClient: false
|
|
3361
|
+
},
|
|
3362
|
+
{
|
|
3363
|
+
name: "get",
|
|
3364
|
+
summary: "Read a setting",
|
|
3365
|
+
args: [{ name: "key", required: true, description: "Setting name" }],
|
|
3366
|
+
run: configGetHandler,
|
|
3367
|
+
needsClient: false
|
|
3368
|
+
},
|
|
3369
|
+
{
|
|
3370
|
+
name: "set",
|
|
3371
|
+
summary: "Change a setting",
|
|
3372
|
+
args: [
|
|
3373
|
+
{
|
|
3374
|
+
name: "key",
|
|
3375
|
+
required: true,
|
|
3376
|
+
description: "url, org, project, format, oauthClientId or iapAudience"
|
|
3377
|
+
},
|
|
3378
|
+
{ name: "value", required: true, description: "The value" }
|
|
3379
|
+
],
|
|
3380
|
+
run: configSetHandler,
|
|
3381
|
+
needsClient: false
|
|
3382
|
+
},
|
|
3383
|
+
{
|
|
3384
|
+
name: "unset",
|
|
3385
|
+
summary: "Remove a setting",
|
|
3386
|
+
args: [{ name: "key", required: true, description: "Setting name" }],
|
|
3387
|
+
run: configUnsetHandler,
|
|
3388
|
+
needsClient: false
|
|
3389
|
+
},
|
|
3390
|
+
{
|
|
3391
|
+
name: "profiles",
|
|
3392
|
+
summary: "List profiles",
|
|
3393
|
+
run: configProfilesHandler,
|
|
3394
|
+
needsClient: false
|
|
3395
|
+
},
|
|
3396
|
+
{
|
|
3397
|
+
name: "use",
|
|
3398
|
+
summary: "Switch the default profile",
|
|
3399
|
+
args: [{ name: "profile", required: true, description: "Profile name" }],
|
|
3400
|
+
run: configUseHandler,
|
|
3401
|
+
needsClient: false
|
|
3402
|
+
},
|
|
3403
|
+
{
|
|
3404
|
+
name: "path",
|
|
3405
|
+
summary: "Where the config file is",
|
|
3406
|
+
run: configPathHandler,
|
|
3407
|
+
needsClient: false
|
|
3408
|
+
}
|
|
3409
|
+
]
|
|
3410
|
+
},
|
|
3411
|
+
{
|
|
3412
|
+
name: "doctor",
|
|
3413
|
+
summary: "Check configuration, credentials and connectivity",
|
|
3414
|
+
needsClient: false,
|
|
3415
|
+
run: doctorHandler
|
|
3416
|
+
},
|
|
3417
|
+
{
|
|
3418
|
+
name: "completion",
|
|
3419
|
+
summary: "Print a shell completion script",
|
|
3420
|
+
description: "For a short name, also add `alias yc=ycadence` to your shell.",
|
|
3421
|
+
needsClient: false,
|
|
3422
|
+
args: [{ name: "shell", required: true, description: `One of ${SHELLS.join(", ")}` }],
|
|
3423
|
+
examples: [
|
|
3424
|
+
"source <(ycadence completion bash)",
|
|
3425
|
+
"source <(ycadence completion zsh)",
|
|
3426
|
+
"ycadence completion fish > ~/.config/fish/completions/ycadence.fish"
|
|
3427
|
+
],
|
|
3428
|
+
run: async (context) => {
|
|
3429
|
+
if (!SHELLS.includes(context.args.shell)) {
|
|
3430
|
+
throw new UsageError(`Shell is one of ${SHELLS.join(", ")}.`);
|
|
3431
|
+
}
|
|
3432
|
+
return completion(context);
|
|
3433
|
+
}
|
|
3434
|
+
},
|
|
3435
|
+
{
|
|
3436
|
+
name: "help",
|
|
3437
|
+
summary: "Help for a command",
|
|
3438
|
+
needsClient: false,
|
|
3439
|
+
args: [{ name: "command", variadic: true, description: "The command, e.g. `work create`" }],
|
|
3440
|
+
run: help
|
|
3441
|
+
},
|
|
3442
|
+
{ name: "version", summary: "Print the version", needsClient: false, run: version },
|
|
3443
|
+
{
|
|
3444
|
+
name: "work-item",
|
|
3445
|
+
summary: "Old spelling of `work`",
|
|
3446
|
+
hidden: true,
|
|
3447
|
+
deprecatedFor: "ycadence work",
|
|
3448
|
+
children: [
|
|
3449
|
+
{
|
|
3450
|
+
name: "list",
|
|
3451
|
+
summary: "Old spelling of `work list`",
|
|
3452
|
+
deprecatedFor: "ycadence work list --project <projectId>",
|
|
3453
|
+
args: [legacyProjectArg],
|
|
3454
|
+
run: listWork
|
|
3455
|
+
},
|
|
3456
|
+
{
|
|
3457
|
+
name: "create",
|
|
3458
|
+
summary: "Old spelling of `work create`",
|
|
3459
|
+
deprecatedFor: "ycadence work create --project <projectId> --title …",
|
|
3460
|
+
mutates: true,
|
|
3461
|
+
args: [legacyProjectArg],
|
|
3462
|
+
flags: [WORK_FIELD_FLAGS.title, WORK_FIELD_FLAGS.description, WORK_FIELD_FLAGS.priority],
|
|
3463
|
+
run: createWork
|
|
3464
|
+
},
|
|
3465
|
+
{
|
|
3466
|
+
name: "show",
|
|
3467
|
+
summary: "Old spelling of `work get`",
|
|
3468
|
+
deprecatedFor: "ycadence work get <workItem>",
|
|
3469
|
+
args: [workItemArg],
|
|
3470
|
+
run: getWork
|
|
3471
|
+
},
|
|
3472
|
+
{
|
|
3473
|
+
name: "close",
|
|
3474
|
+
summary: "Old spelling of `work close`",
|
|
3475
|
+
deprecatedFor: "ycadence work close <workItem>",
|
|
3476
|
+
mutates: true,
|
|
3477
|
+
args: [workItemArg],
|
|
3478
|
+
run: closeWork
|
|
3479
|
+
}
|
|
3480
|
+
]
|
|
3481
|
+
}
|
|
3482
|
+
]
|
|
3483
|
+
};
|
|
3484
|
+
|
|
3485
|
+
// src/runner.ts
|
|
3486
|
+
var IDENTIFIER = /^[A-Z][A-Z0-9_]{0,15}$/;
|
|
3487
|
+
function lazySecretStore(create) {
|
|
3488
|
+
let store;
|
|
3489
|
+
let pending;
|
|
3490
|
+
const ready = async () => {
|
|
3491
|
+
if (store !== undefined)
|
|
3492
|
+
return store;
|
|
3493
|
+
pending ??= create();
|
|
3494
|
+
store = await pending;
|
|
3495
|
+
return store;
|
|
3496
|
+
};
|
|
3497
|
+
return {
|
|
3498
|
+
get kind() {
|
|
3499
|
+
return store?.kind ?? "memory";
|
|
3500
|
+
},
|
|
3501
|
+
get description() {
|
|
3502
|
+
return store?.description ?? "not yet opened";
|
|
3503
|
+
},
|
|
3504
|
+
get: async (account) => (await ready()).get(account),
|
|
3505
|
+
set: async (account, value) => (await ready()).set(account, value),
|
|
3506
|
+
delete: async (account) => (await ready()).delete(account)
|
|
3507
|
+
};
|
|
3508
|
+
}
|
|
3509
|
+
function redactHeaders(headers) {
|
|
3510
|
+
const result = {};
|
|
3511
|
+
for (const [name, value] of Object.entries(headers ?? {})) {
|
|
3512
|
+
const lower = name.toLowerCase();
|
|
3513
|
+
result[name] = lower === "x-api-key" || lower === "authorization" || lower === "cookie" ? "[redacted]" : value;
|
|
3514
|
+
}
|
|
3515
|
+
return result;
|
|
3516
|
+
}
|
|
3517
|
+
function dryRunFetch(inner) {
|
|
3518
|
+
return async (input, init) => {
|
|
3519
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
3520
|
+
if (method === "GET" || method === "HEAD")
|
|
3521
|
+
return inner(input, init);
|
|
3522
|
+
let body;
|
|
3523
|
+
if (typeof init?.body === "string") {
|
|
3524
|
+
try {
|
|
3525
|
+
body = JSON.parse(init.body);
|
|
3526
|
+
} catch {
|
|
3527
|
+
body = init.body;
|
|
3528
|
+
}
|
|
3529
|
+
}
|
|
3530
|
+
throw new RequestIntercepted({
|
|
3531
|
+
method,
|
|
3532
|
+
url: String(input),
|
|
3533
|
+
headers: redactHeaders(init?.headers),
|
|
3534
|
+
body
|
|
3535
|
+
});
|
|
3536
|
+
};
|
|
3537
|
+
}
|
|
3538
|
+
function errorCode(error) {
|
|
3539
|
+
if (error instanceof YCadenceError)
|
|
3540
|
+
return error.code;
|
|
3541
|
+
if (error instanceof UsageError || error instanceof ConfigError || error instanceof CliError) {
|
|
3542
|
+
return error.code;
|
|
3543
|
+
}
|
|
3544
|
+
return "error";
|
|
3545
|
+
}
|
|
3546
|
+
async function run(argv, context) {
|
|
3547
|
+
const tree = context.tree ?? TREE;
|
|
3548
|
+
const stdout = [];
|
|
3549
|
+
const stderr = [];
|
|
3550
|
+
let globals = {
|
|
3551
|
+
format: "table",
|
|
3552
|
+
quiet: false,
|
|
3553
|
+
verbose: false
|
|
3554
|
+
};
|
|
3555
|
+
let requestId;
|
|
3556
|
+
let path = [];
|
|
3557
|
+
const finish = (exitCode) => {
|
|
3558
|
+
const out = stdout.join(`
|
|
454
3559
|
`);
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
3560
|
+
const err = stderr.join(`
|
|
3561
|
+
`);
|
|
3562
|
+
return { stdout: out, stderr: err, exitCode, output: exitCode === 0 ? out : err || out };
|
|
3563
|
+
};
|
|
3564
|
+
try {
|
|
3565
|
+
if (argv.includes("--json") || argv.includes("--format=json"))
|
|
3566
|
+
globals.format = "json";
|
|
3567
|
+
const formatAt = argv.indexOf("--format");
|
|
3568
|
+
if (formatAt !== -1 && argv[formatAt + 1] === "json")
|
|
3569
|
+
globals.format = "json";
|
|
3570
|
+
const parsed = parse(tree, argv);
|
|
3571
|
+
path = parsed.path;
|
|
3572
|
+
if (parsed.help || parsed.command.run === undefined) {
|
|
3573
|
+
const text = renderHelp(tree, parsed.path, parsed.command);
|
|
3574
|
+
if (parsed.help || parsed.path.length === 0) {
|
|
3575
|
+
stdout.push(text);
|
|
3576
|
+
return finish(EXIT.ok);
|
|
3577
|
+
}
|
|
3578
|
+
throw new UsageError(`"${commandName(parsed.path)}" needs a command.
|
|
3579
|
+
|
|
3580
|
+
${text}`);
|
|
3581
|
+
}
|
|
3582
|
+
const directory = configDirectory(context.env);
|
|
3583
|
+
const configStore = context.configStore ?? (directory === undefined ? memoryConfigStore() : fileConfigStore(directory));
|
|
3584
|
+
const flagsForConfig = {
|
|
3585
|
+
profile: parsed.flags.profile,
|
|
3586
|
+
org: parsed.flags.org,
|
|
3587
|
+
project: parsed.flags.project
|
|
3588
|
+
};
|
|
3589
|
+
let config = resolveConfig(await configStore.load(), context.env, flagsForConfig);
|
|
3590
|
+
const options = globalOptions(parsed.flags, { format: config.format });
|
|
3591
|
+
globals = options;
|
|
3592
|
+
const secrets = context.secrets ?? (directory === undefined ? lazySecretStore(async () => detectSecretStore({ directory: undefined, env: { YCADENCE_CREDENTIAL_STORE: "file" } })) : lazySecretStore(() => detectSecretStore({ directory, env: context.env })));
|
|
3593
|
+
const notice = (message) => {
|
|
3594
|
+
if (options.quiet)
|
|
3595
|
+
return;
|
|
3596
|
+
if (context.notice === undefined)
|
|
3597
|
+
stderr.push(message);
|
|
3598
|
+
else
|
|
3599
|
+
context.notice(message);
|
|
3600
|
+
};
|
|
3601
|
+
const debug = (message) => {
|
|
3602
|
+
if (options.verbose)
|
|
3603
|
+
stderr.push(message);
|
|
3604
|
+
};
|
|
3605
|
+
if (parsed.command.deprecatedFor !== undefined) {
|
|
3606
|
+
notice(`"${commandName(parsed.path)}" is deprecated; use "${parsed.command.deprecatedFor}".`);
|
|
3607
|
+
}
|
|
3608
|
+
const isTTY = context.isTTY ?? false;
|
|
3609
|
+
const fetchImpl = context.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
3610
|
+
const now = context.now ?? Date.now;
|
|
3611
|
+
const sleep = context.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
3612
|
+
const onResponse = (info) => {
|
|
3613
|
+
if (info.requestId !== undefined)
|
|
3614
|
+
requestId = info.requestId;
|
|
3615
|
+
debug(`→ ${info.method} ${info.path} ${info.status} ${info.durationMs}ms` + `${info.requestId === undefined ? "" : ` request-id=${info.requestId}`}` + `${info.attempt > 0 ? ` (retry ${info.attempt})` : ""}${info.replayed ? " (replayed)" : ""}`);
|
|
3616
|
+
};
|
|
3617
|
+
let client;
|
|
3618
|
+
const getClient = async () => {
|
|
3619
|
+
if (client !== undefined)
|
|
3620
|
+
return client;
|
|
3621
|
+
let base = context.client;
|
|
3622
|
+
if (base === undefined) {
|
|
3623
|
+
const apiKey = readEnv("API_KEY", context.env) ?? await secrets.get(secretAccount.apiKey(config.profile));
|
|
3624
|
+
if (config.url === undefined || apiKey === undefined) {
|
|
3625
|
+
throw new ConfigError("Set YCADENCE_URL and YCADENCE_API_KEY first, or run `ycadence login`.");
|
|
3626
|
+
}
|
|
3627
|
+
debug(`profile "${config.profile}", ${config.url}, key ${apiKey.slice(0, 12)}…`);
|
|
3628
|
+
const envToken = readEnv("IDENTITY_TOKEN", context.env);
|
|
3629
|
+
base = new YCadenceClient({
|
|
3630
|
+
baseUrl: config.url,
|
|
3631
|
+
apiKey,
|
|
3632
|
+
identityToken: envToken ?? storedIdentityToken({ config, secrets, env: context.env, fetch: fetchImpl, now }),
|
|
3633
|
+
fetch: fetchImpl,
|
|
3634
|
+
sleep
|
|
3635
|
+
});
|
|
3636
|
+
}
|
|
3637
|
+
const inner = base.fetchImplementation;
|
|
3638
|
+
client = base.withOptions({
|
|
3639
|
+
userAgent: `ycadence-cli/${CLI_VERSION}`,
|
|
3640
|
+
onResponse,
|
|
3641
|
+
fetch: options.dryRun ? dryRunFetch(inner) : inner,
|
|
3642
|
+
...options.retry === undefined ? {} : { retry: { retries: options.retry } }
|
|
3643
|
+
});
|
|
3644
|
+
return client;
|
|
3645
|
+
};
|
|
3646
|
+
const generatedKey = options.idempotencyKey === undefined && (options.retry ?? 0) > 0 && parsed.command.mutates === true ? randomUUID() : undefined;
|
|
3647
|
+
if (generatedKey !== undefined)
|
|
3648
|
+
debug(`Idempotency-Key ${generatedKey} (generated for --retry)`);
|
|
3649
|
+
const handlerContext = {
|
|
3650
|
+
root: tree,
|
|
3651
|
+
path: parsed.path,
|
|
3652
|
+
args: parsed.args,
|
|
3653
|
+
flags: parsed.flags,
|
|
3654
|
+
rest: parsed.rest,
|
|
3655
|
+
globals: options,
|
|
3656
|
+
env: context.env,
|
|
3657
|
+
get config() {
|
|
3658
|
+
return config;
|
|
3659
|
+
},
|
|
3660
|
+
configStore,
|
|
3661
|
+
secrets,
|
|
3662
|
+
isTTY,
|
|
3663
|
+
async reloadConfig() {
|
|
3664
|
+
config = resolveConfig(await configStore.load(), context.env, flagsForConfig);
|
|
3665
|
+
client = undefined;
|
|
3666
|
+
},
|
|
3667
|
+
client: getClient,
|
|
3668
|
+
async projectId(positional) {
|
|
3669
|
+
const value = positional ?? options.project ?? config.project;
|
|
3670
|
+
if (value === undefined || value === "") {
|
|
3671
|
+
throw new ConfigError("This command needs a project: pass --project <id>, or set a default with `ycadence config set project <id>`.");
|
|
3672
|
+
}
|
|
3673
|
+
if (isUuid(value) || !IDENTIFIER.test(value))
|
|
3674
|
+
return value;
|
|
3675
|
+
return resolveProjectId(await getClient(), value);
|
|
3676
|
+
},
|
|
3677
|
+
mutationOptions(extra = {}) {
|
|
3678
|
+
const key = options.idempotencyKey ?? generatedKey;
|
|
3679
|
+
return {
|
|
3680
|
+
...extra,
|
|
3681
|
+
...key === undefined ? {} : { idempotencyKey: key },
|
|
3682
|
+
...options.retry === undefined ? {} : { retries: options.retry }
|
|
3683
|
+
};
|
|
3684
|
+
},
|
|
3685
|
+
readFile: context.readFile,
|
|
3686
|
+
readStdin: context.readStdin ?? (async () => {
|
|
3687
|
+
throw new UsageError("Nothing to read on stdin.");
|
|
3688
|
+
}),
|
|
3689
|
+
async confirm(question) {
|
|
3690
|
+
if (options.yes || options.dryRun)
|
|
3691
|
+
return true;
|
|
3692
|
+
if (!isTTY || context.confirm === undefined) {
|
|
3693
|
+
throw new UsageError(`"${commandName(parsed.path)}" needs --yes when not run in a terminal.`);
|
|
3694
|
+
}
|
|
3695
|
+
return context.confirm(question);
|
|
3696
|
+
},
|
|
3697
|
+
notice,
|
|
3698
|
+
debug,
|
|
3699
|
+
fetch: fetchImpl,
|
|
3700
|
+
openUrl: context.openUrl ?? (async () => false),
|
|
3701
|
+
now,
|
|
3702
|
+
sleep
|
|
3703
|
+
};
|
|
3704
|
+
try {
|
|
3705
|
+
const output = await parsed.command.run(handlerContext);
|
|
3706
|
+
const text = formatSuccess(output, options, requestId);
|
|
3707
|
+
if (text !== "")
|
|
3708
|
+
stdout.push(text);
|
|
3709
|
+
return finish(output.exitCode ?? EXIT.ok);
|
|
3710
|
+
} catch (error) {
|
|
3711
|
+
if (error instanceof RequestIntercepted) {
|
|
3712
|
+
stdout.push(formatDryRun(error.request, options));
|
|
3713
|
+
return finish(EXIT.ok);
|
|
3714
|
+
}
|
|
3715
|
+
throw error;
|
|
3716
|
+
}
|
|
3717
|
+
} catch (error) {
|
|
3718
|
+
const code = errorCode(error);
|
|
3719
|
+
let message = formatError(error, code, globals, requestId);
|
|
3720
|
+
if (error instanceof UsageError && globals.format === "table" && !message.includes("Usage:") && !message.includes(`
|
|
3721
|
+
|
|
3722
|
+
`)) {
|
|
3723
|
+
message += `
|
|
3724
|
+
Run \`${commandName(path)} --help\` for usage.`;
|
|
3725
|
+
}
|
|
3726
|
+
if (!(error instanceof YCadenceError || error instanceof UsageError || error instanceof ConfigError || error instanceof CliError)) {
|
|
3727
|
+
if (globals.verbose && error instanceof Error && error.stack)
|
|
3728
|
+
stderr.push(error.stack);
|
|
3729
|
+
}
|
|
3730
|
+
stderr.push(message);
|
|
3731
|
+
return finish(exitCodeFor(error));
|
|
3732
|
+
}
|
|
3733
|
+
}
|
|
3734
|
+
|
|
3735
|
+
// src/bin.ts
|
|
3736
|
+
async function readStdin() {
|
|
3737
|
+
if (process.stdin.isTTY)
|
|
3738
|
+
return "";
|
|
3739
|
+
const chunks = [];
|
|
3740
|
+
for await (const chunk of process.stdin)
|
|
3741
|
+
chunks.push(chunk);
|
|
3742
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
3743
|
+
}
|
|
3744
|
+
function confirm(question) {
|
|
3745
|
+
const prompt = createInterface({ input: process.stdin, output: process.stderr });
|
|
3746
|
+
return new Promise((resolve) => {
|
|
3747
|
+
prompt.question(`${question} [y/N] `, (answer) => {
|
|
3748
|
+
prompt.close();
|
|
3749
|
+
resolve(/^y(es)?$/i.test(answer.trim()));
|
|
3750
|
+
});
|
|
3751
|
+
});
|
|
3752
|
+
}
|
|
3753
|
+
function openUrl(url) {
|
|
3754
|
+
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
3755
|
+
return new Promise((resolve) => {
|
|
3756
|
+
try {
|
|
3757
|
+
const child = spawn2(command, args, { stdio: "ignore", detached: true });
|
|
3758
|
+
child.on("error", () => resolve(false));
|
|
3759
|
+
child.on("spawn", () => {
|
|
3760
|
+
child.unref();
|
|
3761
|
+
resolve(true);
|
|
3762
|
+
});
|
|
3763
|
+
} catch {
|
|
3764
|
+
resolve(false);
|
|
3765
|
+
}
|
|
3766
|
+
});
|
|
3767
|
+
}
|
|
3768
|
+
var result = await run(process.argv.slice(2), {
|
|
463
3769
|
env: process.env,
|
|
464
|
-
readFile: (path) =>
|
|
3770
|
+
readFile: (path) => readFile3(path, "utf8"),
|
|
3771
|
+
readStdin,
|
|
3772
|
+
isTTY: process.stdin.isTTY === true && process.stderr.isTTY === true,
|
|
3773
|
+
confirm,
|
|
3774
|
+
openUrl,
|
|
3775
|
+
notice: (message) => process.stderr.write(`${message}
|
|
3776
|
+
`)
|
|
465
3777
|
});
|
|
466
|
-
|
|
467
|
-
|
|
3778
|
+
if (result.stdout !== "")
|
|
3779
|
+
process.stdout.write(`${result.stdout}
|
|
3780
|
+
`);
|
|
3781
|
+
if (result.stderr !== "")
|
|
3782
|
+
process.stderr.write(`${result.stderr}
|
|
468
3783
|
`);
|
|
469
|
-
process.
|
|
3784
|
+
process.exitCode = result.exitCode;
|