@enrichlayer/el-linear 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/dist/auth/oauth-callback.d.ts +40 -0
- package/dist/auth/oauth-callback.js +142 -0
- package/dist/auth/oauth-client.d.ts +55 -0
- package/dist/auth/oauth-client.js +134 -0
- package/dist/auth/oauth-fs.d.ts +1 -0
- package/dist/auth/oauth-fs.js +29 -0
- package/dist/auth/oauth-headless.d.ts +38 -0
- package/dist/auth/oauth-headless.js +50 -0
- package/dist/auth/oauth-storage.d.ts +51 -0
- package/dist/auth/oauth-storage.js +87 -0
- package/dist/auth/oauth-token.d.ts +70 -0
- package/dist/auth/oauth-token.js +141 -0
- package/dist/auth/token-resolver.d.ts +48 -0
- package/dist/auth/token-resolver.js +95 -0
- package/dist/commands/attachments.js +11 -12
- package/dist/commands/batch.js +15 -14
- package/dist/commands/comments.js +22 -10
- package/dist/commands/cycles.js +5 -4
- package/dist/commands/documents.js +18 -15
- package/dist/commands/embeds.js +4 -6
- package/dist/commands/graphql.js +5 -4
- package/dist/commands/init/aliases.js +0 -14
- package/dist/commands/init/defaults.js +0 -6
- package/dist/commands/init/index.js +22 -12
- package/dist/commands/init/oauth.d.ts +85 -0
- package/dist/commands/init/oauth.js +308 -0
- package/dist/commands/init/shared.js +0 -1
- package/dist/commands/init/token.js +0 -7
- package/dist/commands/init/workspace.js +0 -2
- package/dist/commands/issue-id.js +3 -1
- package/dist/commands/issues.js +87 -46
- package/dist/commands/labels.js +9 -8
- package/dist/commands/profile/migrate-legacy.d.ts +96 -0
- package/dist/commands/profile/migrate-legacy.js +271 -0
- package/dist/commands/profile.js +6 -0
- package/dist/commands/project-milestones.js +13 -12
- package/dist/commands/projects.js +18 -14
- package/dist/commands/read-shortcut.js +8 -7
- package/dist/commands/refs.js +4 -3
- package/dist/commands/releases.js +9 -8
- package/dist/commands/search.js +3 -2
- package/dist/commands/teams.js +3 -2
- package/dist/commands/templates.js +5 -4
- package/dist/commands/users.js +3 -2
- package/dist/config/config.d.ts +18 -0
- package/dist/config/issue-validation.js +1 -1
- package/dist/config/resolver.js +1 -1
- package/dist/main.js +1 -1
- package/dist/utils/auth.js +8 -0
- package/dist/utils/download-uploads.d.ts +2 -1
- package/dist/utils/download-uploads.js +2 -4
- package/dist/utils/file-service.d.ts +26 -2
- package/dist/utils/file-service.js +27 -5
- package/dist/utils/footer.d.ts +19 -0
- package/dist/utils/footer.js +27 -0
- package/dist/utils/gdoc-parser.js +1 -1
- package/dist/utils/graphql-attachments-service.d.ts +1 -1
- package/dist/utils/graphql-attachments-service.js +2 -2
- package/dist/utils/graphql-documents-service.d.ts +1 -1
- package/dist/utils/graphql-documents-service.js +2 -2
- package/dist/utils/graphql-service.d.ts +18 -3
- package/dist/utils/graphql-service.js +26 -11
- package/dist/utils/legacy-config-detection.d.ts +47 -0
- package/dist/utils/legacy-config-detection.js +90 -0
- package/dist/utils/linear-service.d.ts +18 -3
- package/dist/utils/linear-service.js +21 -6
- package/dist/utils/markdown-prosemirror.js +16 -9
- package/dist/utils/migration-hint.d.ts +46 -0
- package/dist/utils/migration-hint.js +90 -0
- package/dist/utils/root-opts.d.ts +11 -0
- package/dist/utils/root-opts.js +13 -0
- package/dist/utils/validators.d.ts +7 -0
- package/dist/utils/validators.js +14 -14
- package/package.json +1 -1
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wizard step for OAuth 2.0 (PKCE flow) authorization.
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* 1. Present a "what is this?" intro pointing the user at Linear's OAuth
|
|
6
|
+
* app registration page. (Until we ship a shared OAuth client_id,
|
|
7
|
+
* every user registers their own app.)
|
|
8
|
+
* 2. Prompt for `client_id`, optional `client_secret`, port, scopes.
|
|
9
|
+
* 3. Generate PKCE verifier + state, build the authorize URL.
|
|
10
|
+
* 4. Try to open the system browser; fall back to printing the URL.
|
|
11
|
+
* 5. Spin a localhost listener (or fall back to pasted-code prompt) to
|
|
12
|
+
* receive the redirect.
|
|
13
|
+
* 6. Exchange code for tokens.
|
|
14
|
+
* 7. Validate by calling `viewer { ... }` with the new bearer token.
|
|
15
|
+
* 8. Persist `oauth.json` to the active profile.
|
|
16
|
+
*
|
|
17
|
+
* Idempotent: if a fresh `oauth.json` already exists, offer keep / re-auth /
|
|
18
|
+
* revoke before doing anything else.
|
|
19
|
+
*/
|
|
20
|
+
import { spawn } from "node:child_process";
|
|
21
|
+
import { checkbox, input, password, select } from "@inquirer/prompts";
|
|
22
|
+
import { DEFAULT_CALLBACK_PATH, runLocalhostCallback, } from "../../auth/oauth-callback.js";
|
|
23
|
+
import { ALL_SCOPES, buildAuthorizeUrl, DEFAULT_SCOPES, generatePkce, generateState, SCOPE_DESCRIPTIONS, validateScopes, } from "../../auth/oauth-client.js";
|
|
24
|
+
import { promptForPastedCode } from "../../auth/oauth-headless.js";
|
|
25
|
+
import { clearOAuthState, OAUTH_STATE_VERSION, readOAuthState, writeOAuthState, } from "../../auth/oauth-storage.js";
|
|
26
|
+
import { exchangeCodeForTokens, revokeToken, } from "../../auth/oauth-token.js";
|
|
27
|
+
import { GraphQLService } from "../../utils/graphql-service.js";
|
|
28
|
+
import { sanitizeForLog } from "./token.js";
|
|
29
|
+
const DEFAULT_PORT = 8765;
|
|
30
|
+
const REGISTRATION_URL = "https://linear.app/settings/api/applications/new";
|
|
31
|
+
const VIEWER_QUERY = /* GraphQL */ `
|
|
32
|
+
query {
|
|
33
|
+
viewer {
|
|
34
|
+
id
|
|
35
|
+
name
|
|
36
|
+
email
|
|
37
|
+
displayName
|
|
38
|
+
organization {
|
|
39
|
+
urlKey
|
|
40
|
+
name
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
`;
|
|
45
|
+
const TS = (msg) => ` ${msg}`;
|
|
46
|
+
function logLine(msg) {
|
|
47
|
+
console.log(msg);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Open `url` in the system browser. Forks per-platform:
|
|
51
|
+
* - darwin → `open <url>`
|
|
52
|
+
* - win32 → `start "" "<url>"` (cmd builtin)
|
|
53
|
+
* - other → `xdg-open <url>` (Linux / BSD with desktop env)
|
|
54
|
+
*
|
|
55
|
+
* Returns once the spawn call succeeds — does NOT wait for the browser to
|
|
56
|
+
* actually load. Throws on spawn failure (e.g. xdg-open not installed in
|
|
57
|
+
* a barebones container).
|
|
58
|
+
*/
|
|
59
|
+
export async function openSystemBrowser(url) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
let cmd;
|
|
62
|
+
let args;
|
|
63
|
+
if (process.platform === "darwin") {
|
|
64
|
+
cmd = "open";
|
|
65
|
+
args = [url];
|
|
66
|
+
}
|
|
67
|
+
else if (process.platform === "win32") {
|
|
68
|
+
cmd = "cmd";
|
|
69
|
+
// `start "" "<url>"` — empty title means "use the URL".
|
|
70
|
+
args = ["/c", "start", "", url];
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
cmd = "xdg-open";
|
|
74
|
+
args = [url];
|
|
75
|
+
}
|
|
76
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
77
|
+
child.once("error", reject);
|
|
78
|
+
// Don't keep the parent alive on the child — we don't track its exit.
|
|
79
|
+
child.unref();
|
|
80
|
+
// Linux's xdg-open immediately exits with the result code. Wait
|
|
81
|
+
// until the next tick so a synchronous `error` event has a chance
|
|
82
|
+
// to fire before we resolve.
|
|
83
|
+
setImmediate(() => resolve());
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Prompt for what to do with existing OAuth state. Returns a discriminated
|
|
88
|
+
* tagged union so the caller can branch cleanly.
|
|
89
|
+
*/
|
|
90
|
+
async function handleExistingState(existing, options) {
|
|
91
|
+
if (options.force)
|
|
92
|
+
return { kind: "reauth" };
|
|
93
|
+
logLine(TS(`Existing OAuth tokens found for client ${existing.clientId} (${existing.scopes.join(",")}).`));
|
|
94
|
+
const choice = await select({
|
|
95
|
+
message: "What would you like to do?",
|
|
96
|
+
choices: [
|
|
97
|
+
{ name: "Keep existing tokens (no changes)", value: "keep" },
|
|
98
|
+
{ name: "Re-authorize (replace tokens)", value: "reauth" },
|
|
99
|
+
{ name: "Revoke and remove", value: "revoke" },
|
|
100
|
+
],
|
|
101
|
+
default: "keep",
|
|
102
|
+
});
|
|
103
|
+
if (choice === "keep")
|
|
104
|
+
return { kind: "keep", state: existing };
|
|
105
|
+
if (choice === "revoke") {
|
|
106
|
+
const result = await revokeToken({ accessToken: existing.accessToken }, options.fetchImpl);
|
|
107
|
+
await clearOAuthState();
|
|
108
|
+
logLine(TS(result.ok
|
|
109
|
+
? "✓ Token revoked and oauth.json removed."
|
|
110
|
+
: `Revoke endpoint returned ${result.status} (${sanitizeForLog(result.message ?? "")}). Local oauth.json removed anyway.`));
|
|
111
|
+
return { kind: "revoked" };
|
|
112
|
+
}
|
|
113
|
+
return { kind: "reauth" };
|
|
114
|
+
}
|
|
115
|
+
/** Extract the port from a stored `http://localhost:NNN/...` redirect URI, if any. */
|
|
116
|
+
function extractPortFromRedirect(state) {
|
|
117
|
+
if (!state)
|
|
118
|
+
return null;
|
|
119
|
+
const match = state.registeredRedirectUri.match(/:(\d+)\//);
|
|
120
|
+
if (!match)
|
|
121
|
+
return null;
|
|
122
|
+
const n = Number.parseInt(match[1], 10);
|
|
123
|
+
return Number.isInteger(n) ? n : null;
|
|
124
|
+
}
|
|
125
|
+
/** Walk the user through their OAuth-app registration values. */
|
|
126
|
+
async function promptRegistration(defaults) {
|
|
127
|
+
logLine("");
|
|
128
|
+
logLine(TS(`Register a Linear OAuth app: ${REGISTRATION_URL}`));
|
|
129
|
+
logLine(TS(`Set the redirect URL to: http://localhost:${defaults.port ?? DEFAULT_PORT}${DEFAULT_CALLBACK_PATH}`));
|
|
130
|
+
logLine(TS("Then paste the client_id (and client_secret, if your app is configured as confidential)."));
|
|
131
|
+
logLine("");
|
|
132
|
+
const port = Number.parseInt(await input({
|
|
133
|
+
message: "Localhost callback port:",
|
|
134
|
+
default: String(defaults.port ?? DEFAULT_PORT),
|
|
135
|
+
validate: (value) => {
|
|
136
|
+
const n = Number.parseInt(value, 10);
|
|
137
|
+
if (!Number.isInteger(n) || n < 1024 || n > 65535) {
|
|
138
|
+
return "Port must be an integer between 1024 and 65535";
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
},
|
|
142
|
+
}), 10);
|
|
143
|
+
const clientId = (await input({
|
|
144
|
+
message: "Linear OAuth client_id:",
|
|
145
|
+
validate: (v) => v.trim().length > 0 || "client_id cannot be empty",
|
|
146
|
+
})).trim();
|
|
147
|
+
const clientSecret = (await password({
|
|
148
|
+
message: "Linear OAuth client_secret (optional, hidden — press enter to skip):",
|
|
149
|
+
mask: "*",
|
|
150
|
+
validate: () => true,
|
|
151
|
+
})).trim();
|
|
152
|
+
const scopes = (await checkbox({
|
|
153
|
+
message: "Scopes (space to toggle, enter to confirm):",
|
|
154
|
+
choices: ALL_SCOPES.map((s) => ({
|
|
155
|
+
name: `${s} — ${SCOPE_DESCRIPTIONS[s]}`,
|
|
156
|
+
value: s,
|
|
157
|
+
checked: DEFAULT_SCOPES.includes(s),
|
|
158
|
+
})),
|
|
159
|
+
validate: (selections) => selections.length > 0 || "Pick at least one scope",
|
|
160
|
+
}));
|
|
161
|
+
return {
|
|
162
|
+
clientId,
|
|
163
|
+
clientSecret: clientSecret || undefined,
|
|
164
|
+
port,
|
|
165
|
+
scopes: validateScopes(scopes),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Default viewer-validation routine. Calls `viewer { ... }` with the new
|
|
170
|
+
* bearer token to confirm Linear accepted it. Reused for both the wizard
|
|
171
|
+
* step's success path and the test seam.
|
|
172
|
+
*/
|
|
173
|
+
async function defaultValidateViewer(oauthToken) {
|
|
174
|
+
const service = new GraphQLService({ oauthToken });
|
|
175
|
+
let data;
|
|
176
|
+
try {
|
|
177
|
+
data = await service.rawRequest(VIEWER_QUERY);
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
181
|
+
throw new Error(`Could not validate the OAuth access token via viewer: ${sanitizeForLog(raw)}`);
|
|
182
|
+
}
|
|
183
|
+
const viewer = data?.viewer;
|
|
184
|
+
if (!viewer || typeof viewer !== "object" || typeof viewer.id !== "string") {
|
|
185
|
+
throw new Error("OAuth token validated but the response was missing a viewer with id. Try a different scope set.");
|
|
186
|
+
}
|
|
187
|
+
return viewer;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Run the OAuth step. Returns the final stored state + viewer info.
|
|
191
|
+
*
|
|
192
|
+
* If the user opts to keep their existing tokens, returns the existing
|
|
193
|
+
* state unchanged (no network calls).
|
|
194
|
+
*/
|
|
195
|
+
export async function runOAuthStep(options = {}) {
|
|
196
|
+
const validateViewer = options.validateViewer ?? defaultValidateViewer;
|
|
197
|
+
const existing = await readOAuthState();
|
|
198
|
+
if (existing && !options.force) {
|
|
199
|
+
const handled = await handleExistingState(existing, options);
|
|
200
|
+
if (handled.kind === "keep") {
|
|
201
|
+
// Validate the existing token actually works; if it's already
|
|
202
|
+
// expired and unrefreshable, the next `el-linear` invocation
|
|
203
|
+
// would fall over — fail loudly here.
|
|
204
|
+
try {
|
|
205
|
+
const viewer = await validateViewer(handled.state.accessToken);
|
|
206
|
+
logLine(TS(`✓ Existing OAuth tokens verified — authenticated as ${viewer.displayName} <${viewer.email}>.`));
|
|
207
|
+
return { state: handled.state, viewer };
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
211
|
+
logLine(TS(`Existing token failed validation (${sanitizeForLog(raw)}). Continuing with re-authorization…`));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Both `reauth` and `revoked` fall through to the re-auth flow.
|
|
215
|
+
}
|
|
216
|
+
const reg = await promptRegistration({
|
|
217
|
+
port: options.port ?? extractPortFromRedirect(existing) ?? DEFAULT_PORT,
|
|
218
|
+
});
|
|
219
|
+
const redirectUri = `http://localhost:${reg.port}${DEFAULT_CALLBACK_PATH}`;
|
|
220
|
+
const pkce = generatePkce();
|
|
221
|
+
const state = generateState();
|
|
222
|
+
const authorizeUrl = buildAuthorizeUrl({
|
|
223
|
+
clientId: reg.clientId,
|
|
224
|
+
redirectUri,
|
|
225
|
+
scopes: reg.scopes,
|
|
226
|
+
state,
|
|
227
|
+
codeChallenge: pkce.challenge,
|
|
228
|
+
});
|
|
229
|
+
logLine("");
|
|
230
|
+
logLine(TS("Opening your browser to authorize…"));
|
|
231
|
+
logLine(TS(`If it doesn't open, visit: ${authorizeUrl}`));
|
|
232
|
+
const useBrowser = !options.noBrowser;
|
|
233
|
+
let browserOpened = false;
|
|
234
|
+
if (useBrowser) {
|
|
235
|
+
try {
|
|
236
|
+
await (options.openBrowser ?? openSystemBrowser)(authorizeUrl);
|
|
237
|
+
browserOpened = true;
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
241
|
+
logLine(TS(`Could not open a browser automatically (${raw}). Falling back to manual.`));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
let callback;
|
|
245
|
+
if (browserOpened) {
|
|
246
|
+
try {
|
|
247
|
+
callback = await (options.runLocalhostCallbackImpl ?? runLocalhostCallback)({
|
|
248
|
+
port: reg.port,
|
|
249
|
+
expectedState: state,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
254
|
+
logLine(TS(`Localhost listener failed (${sanitizeForLog(raw)}). Falling back to manual paste.`));
|
|
255
|
+
callback = await promptForPastedCode({ expectedState: state });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
callback = await promptForPastedCode({ expectedState: state });
|
|
260
|
+
}
|
|
261
|
+
logLine(TS("Exchanging authorization code for tokens…"));
|
|
262
|
+
const exchanged = await exchangeCodeForTokens({
|
|
263
|
+
clientId: reg.clientId,
|
|
264
|
+
clientSecret: reg.clientSecret,
|
|
265
|
+
code: callback.code,
|
|
266
|
+
redirectUri,
|
|
267
|
+
codeVerifier: pkce.verifier,
|
|
268
|
+
}, options.fetchImpl);
|
|
269
|
+
const newState = {
|
|
270
|
+
v: OAUTH_STATE_VERSION,
|
|
271
|
+
clientId: reg.clientId,
|
|
272
|
+
clientSecret: reg.clientSecret,
|
|
273
|
+
registeredRedirectUri: redirectUri,
|
|
274
|
+
accessToken: exchanged.accessToken,
|
|
275
|
+
refreshToken: exchanged.refreshToken,
|
|
276
|
+
tokenType: exchanged.tokenType,
|
|
277
|
+
scopes: exchanged.scopes.length > 0 ? exchanged.scopes : reg.scopes,
|
|
278
|
+
expiresAt: exchanged.expiresAt,
|
|
279
|
+
obtainedAt: Date.now(),
|
|
280
|
+
};
|
|
281
|
+
logLine(TS("Validating against viewer…"));
|
|
282
|
+
const viewer = await validateViewer(newState.accessToken);
|
|
283
|
+
await writeOAuthState(newState);
|
|
284
|
+
logLine(TS(`✓ Authorized as ${viewer.displayName} <${viewer.email}> (${viewer.organization.name}).`));
|
|
285
|
+
return { state: newState, viewer };
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* `init oauth --revoke`: revoke the active profile's tokens and remove
|
|
289
|
+
* `oauth.json`. Best-effort on the network call.
|
|
290
|
+
*/
|
|
291
|
+
export async function runOAuthRevoke(options = {}) {
|
|
292
|
+
const existing = await readOAuthState();
|
|
293
|
+
if (!existing) {
|
|
294
|
+
return { revoked: false, message: "No OAuth state to revoke." };
|
|
295
|
+
}
|
|
296
|
+
const result = await revokeToken({ accessToken: existing.accessToken }, options.fetchImpl);
|
|
297
|
+
await clearOAuthState();
|
|
298
|
+
if (result.ok) {
|
|
299
|
+
return {
|
|
300
|
+
revoked: true,
|
|
301
|
+
message: "✓ Token revoked and oauth.json removed.",
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
revoked: false,
|
|
306
|
+
message: `Revoke endpoint returned ${result.status} (${sanitizeForLog(result.message ?? "")}). Local oauth.json removed anyway.`,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
@@ -174,7 +174,6 @@ export async function clearAliasesProgress() {
|
|
|
174
174
|
* Print a step header in the wizard. Step strings like "1/4" or "2/4 (skipped)".
|
|
175
175
|
*/
|
|
176
176
|
export function printStep(label, title) {
|
|
177
|
-
// biome-ignore lint/suspicious/noConsole: wizard output is meant for stdout
|
|
178
177
|
console.log(`\n[${label}] ${title}`);
|
|
179
178
|
}
|
|
180
179
|
/**
|
|
@@ -91,7 +91,6 @@ export async function runTokenStep(options = {}) {
|
|
|
91
91
|
if (existing && !options.force) {
|
|
92
92
|
try {
|
|
93
93
|
const viewer = await validateToken(existing);
|
|
94
|
-
// biome-ignore lint/suspicious/noConsole: wizard
|
|
95
94
|
console.log(` Existing token works — authenticated as ${viewer.displayName} <${viewer.email}>.`);
|
|
96
95
|
const replace = await confirm({
|
|
97
96
|
message: "Replace this token?",
|
|
@@ -104,13 +103,10 @@ export async function runTokenStep(options = {}) {
|
|
|
104
103
|
catch (err) {
|
|
105
104
|
const raw = err instanceof Error ? err.message : String(err);
|
|
106
105
|
const message = sanitizeForLog(raw);
|
|
107
|
-
// biome-ignore lint/suspicious/noConsole: wizard
|
|
108
106
|
console.log(` Existing token failed validation (${message}). You'll need to provide a new one.`);
|
|
109
107
|
}
|
|
110
108
|
}
|
|
111
|
-
// biome-ignore lint/suspicious/noConsole: wizard
|
|
112
109
|
console.log(` Generate a personal API token: ${TOKEN_GENERATION_URL}`);
|
|
113
|
-
// biome-ignore lint/suspicious/noConsole: wizard
|
|
114
110
|
console.log(" The token stays on your machine. el-linear only sends it to Linear's API. " +
|
|
115
111
|
"It's stored at ~/.config/el-linear/token (mode 0600).");
|
|
116
112
|
// Up to three attempts before giving up.
|
|
@@ -123,17 +119,14 @@ export async function runTokenStep(options = {}) {
|
|
|
123
119
|
validate: (input) => input.trim().length > 0 || "Token cannot be empty",
|
|
124
120
|
})).trim();
|
|
125
121
|
try {
|
|
126
|
-
// biome-ignore lint/suspicious/noConsole: wizard
|
|
127
122
|
console.log(" Validating…");
|
|
128
123
|
const viewer = await validateToken(token);
|
|
129
124
|
await writeToken(token);
|
|
130
|
-
// biome-ignore lint/suspicious/noConsole: wizard
|
|
131
125
|
console.log(` ✓ Authenticated as ${viewer.displayName} <${viewer.email}> (${viewer.organization.name}).`);
|
|
132
126
|
return { token, viewer };
|
|
133
127
|
}
|
|
134
128
|
catch (err) {
|
|
135
129
|
const raw = err instanceof Error ? err.message : String(err);
|
|
136
|
-
// biome-ignore lint/suspicious/noConsole: wizard
|
|
137
130
|
console.log(` ✗ ${sanitizeForLog(raw)}`);
|
|
138
131
|
}
|
|
139
132
|
}
|
|
@@ -33,7 +33,6 @@ export async function runWorkspaceStep(token, workspaceUrlKey, existing) {
|
|
|
33
33
|
for (const t of teams) {
|
|
34
34
|
teamMap[t.key] = t.id;
|
|
35
35
|
}
|
|
36
|
-
// biome-ignore lint/suspicious/noConsole: wizard
|
|
37
36
|
console.log(` Workspace: ${workspaceUrlKey} (${teams.length} teams)`);
|
|
38
37
|
const currentDefault = existing.defaultTeam || "";
|
|
39
38
|
// `change` reflects what the user agreed to: `true` means "open the picker
|
|
@@ -53,7 +52,6 @@ export async function runWorkspaceStep(token, workspaceUrlKey, existing) {
|
|
|
53
52
|
};
|
|
54
53
|
}
|
|
55
54
|
if (teams.length === 0) {
|
|
56
|
-
// biome-ignore lint/suspicious/noConsole: wizard
|
|
57
55
|
console.log(" No teams visible to this token. Skipping default-team picker.");
|
|
58
56
|
return {
|
|
59
57
|
workspaceUrlKey,
|
|
@@ -72,7 +72,9 @@ export function setupIssueIdCommand(program) {
|
|
|
72
72
|
return;
|
|
73
73
|
}
|
|
74
74
|
const rootOpts = command.parent?.opts() ?? {};
|
|
75
|
-
const service = createGraphQLService({
|
|
75
|
+
const service = await createGraphQLService({
|
|
76
|
+
apiToken: rootOpts.apiToken,
|
|
77
|
+
});
|
|
76
78
|
const result = (await service.rawRequest(ISSUE_QUERY, {
|
|
77
79
|
id: parsed.issueId,
|
|
78
80
|
}));
|
package/dist/commands/issues.js
CHANGED
|
@@ -7,10 +7,10 @@ import { resolveDefaultStatus } from "../config/status-defaults.js";
|
|
|
7
7
|
import { enforceTerms } from "../config/term-enforcer.js";
|
|
8
8
|
import { LIST_COMMENTS_QUERY } from "../queries/comments.js";
|
|
9
9
|
import { GET_ISSUE_RELATIONS_QUERY, GET_ISSUE_STATE_HISTORY_QUERY, ISSUE_RELATION_CREATE_MUTATION, SCAN_ISSUES_QUERY, UPDATE_ISSUE_MUTATION, } from "../queries/issues.js";
|
|
10
|
-
import { getApiToken } from "../utils/auth.js";
|
|
11
10
|
import { autoLinkReferences, } from "../utils/auto-link-references.js";
|
|
12
11
|
import { downloadLinearUploads } from "../utils/download-uploads.js";
|
|
13
|
-
import {
|
|
12
|
+
import { createFileService } from "../utils/file-service.js";
|
|
13
|
+
import { applyFooter } from "../utils/footer.js";
|
|
14
14
|
import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-service.js";
|
|
15
15
|
import { GraphQLIssuesService } from "../utils/graphql-issues-service.js";
|
|
16
16
|
import { createGraphQLService, } from "../utils/graphql-service.js";
|
|
@@ -19,6 +19,7 @@ import { wrapIssueReferencesAsLinks } from "../utils/issue-reference-wrapper.js"
|
|
|
19
19
|
import { createLinearService, } from "../utils/linear-service.js";
|
|
20
20
|
import { logger } from "../utils/logger.js";
|
|
21
21
|
import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
|
|
22
|
+
import { getRootOpts } from "../utils/root-opts.js";
|
|
22
23
|
import { formatCsv, formatMarkdown, formatTable, } from "../utils/table-formatter.js";
|
|
23
24
|
import { validateReferences } from "../utils/validate-references.js";
|
|
24
25
|
import { parsePriorityFilter, splitList, validatePriority, } from "../utils/validators.js";
|
|
@@ -78,14 +79,43 @@ function readDescriptionFile(filePath) {
|
|
|
78
79
|
return fs.readFileSync(filePath, "utf8").trim();
|
|
79
80
|
}
|
|
80
81
|
/**
|
|
81
|
-
* Resolve the description from --description, --description-file, or
|
|
82
|
-
* --
|
|
82
|
+
* Resolve the description from --description, --description-file, or
|
|
83
|
+
* --template (looked up in `config.descriptionTemplates`).
|
|
84
|
+
*
|
|
85
|
+
* Precedence:
|
|
86
|
+
* --description-file > --description > --template
|
|
87
|
+
*
|
|
88
|
+
* Passing --template alongside --description or --description-file is a
|
|
89
|
+
* usage error — the explicit body and a template both producing content
|
|
90
|
+
* would be silently dropping one. We throw so the user picks one.
|
|
83
91
|
*/
|
|
84
92
|
function resolveDescription(options) {
|
|
85
|
-
|
|
93
|
+
const hasInline = typeof options.description === "string" && options.description.length > 0;
|
|
94
|
+
const hasFile = Boolean(options.descriptionFile);
|
|
95
|
+
const hasTemplate = typeof options.template === "string" && options.template;
|
|
96
|
+
if (hasTemplate && (hasInline || hasFile)) {
|
|
97
|
+
throw new Error("--template is mutually exclusive with --description / --description-file. " +
|
|
98
|
+
"Pick one.");
|
|
99
|
+
}
|
|
100
|
+
if (hasFile) {
|
|
86
101
|
return readDescriptionFile(options.descriptionFile);
|
|
87
102
|
}
|
|
88
|
-
|
|
103
|
+
if (hasInline) {
|
|
104
|
+
return options.description;
|
|
105
|
+
}
|
|
106
|
+
if (hasTemplate) {
|
|
107
|
+
const templates = loadConfig().descriptionTemplates ?? {};
|
|
108
|
+
const body = templates[options.template];
|
|
109
|
+
if (!body) {
|
|
110
|
+
const available = Object.keys(templates).sort();
|
|
111
|
+
const hint = available.length
|
|
112
|
+
? `Available templates: ${available.join(", ")}`
|
|
113
|
+
: "No templates configured. Add one under `descriptionTemplates` in your config.";
|
|
114
|
+
throw new Error(`Template "${options.template}" not found. ${hint}`);
|
|
115
|
+
}
|
|
116
|
+
return body;
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
89
119
|
}
|
|
90
120
|
function isImageFile(filename) {
|
|
91
121
|
const ext = filename.lastIndexOf(".");
|
|
@@ -296,9 +326,9 @@ async function handleListIssues(options, command) {
|
|
|
296
326
|
if (options.label && !options.labels) {
|
|
297
327
|
options.labels = options.label;
|
|
298
328
|
}
|
|
299
|
-
const rootOpts = command
|
|
300
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
301
|
-
const linearService = createLinearService(rootOpts);
|
|
329
|
+
const rootOpts = getRootOpts(command);
|
|
330
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
331
|
+
const linearService = await createLinearService(rootOpts);
|
|
302
332
|
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
303
333
|
const hasFilters = options.team ||
|
|
304
334
|
options.labels ||
|
|
@@ -337,9 +367,9 @@ async function handleSearchIssues(query, options, command) {
|
|
|
337
367
|
if (options.label && !options.labels) {
|
|
338
368
|
options.labels = options.label;
|
|
339
369
|
}
|
|
340
|
-
const rootOpts = command
|
|
341
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
342
|
-
const linearService = createLinearService(rootOpts);
|
|
370
|
+
const rootOpts = getRootOpts(command);
|
|
371
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
372
|
+
const linearService = await createLinearService(rootOpts);
|
|
343
373
|
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
344
374
|
const searchArgs = {
|
|
345
375
|
query,
|
|
@@ -417,8 +447,7 @@ async function uploadAttachmentsIfNeeded(options, rootOpts) {
|
|
|
417
447
|
const paths = Array.isArray(options.attachment)
|
|
418
448
|
? options.attachment
|
|
419
449
|
: [options.attachment];
|
|
420
|
-
const
|
|
421
|
-
const fileService = new FileService(apiToken);
|
|
450
|
+
const fileService = await createFileService(rootOpts);
|
|
422
451
|
const results = [];
|
|
423
452
|
for (const filePath of paths) {
|
|
424
453
|
const result = await fileService.uploadFile(filePath);
|
|
@@ -442,12 +471,21 @@ function buildDescriptionWithAttachments(baseDescription, uploadResults) {
|
|
|
442
471
|
return description;
|
|
443
472
|
}
|
|
444
473
|
async function handleCreateIssue(title, options, command) {
|
|
445
|
-
const rootOpts = command
|
|
474
|
+
const rootOpts = getRootOpts(command);
|
|
446
475
|
const { teamInput, teamId, assigneeId, labelIds, status, subscriberIds } = await resolveCreateInputs(title, options, rootOpts);
|
|
447
476
|
const uploadResults = await uploadAttachmentsIfNeeded(options, rootOpts);
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
477
|
+
const descriptionWithAttachments = buildDescriptionWithAttachments(resolveDescription(options) || "", uploadResults);
|
|
478
|
+
// Append messageFooter (config or --footer flag) so auto-link picks up any
|
|
479
|
+
// issue refs in the footer too. --no-footer skips both flag and config.
|
|
480
|
+
// Commander parses --no-footer as `options.footer === false`.
|
|
481
|
+
const noFooter = options.footer === false;
|
|
482
|
+
const explicitFooter = typeof options.footer === "string" ? options.footer : undefined;
|
|
483
|
+
const description = applyFooter(descriptionWithAttachments, {
|
|
484
|
+
footer: explicitFooter,
|
|
485
|
+
noFooter,
|
|
486
|
+
}) ?? "";
|
|
487
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
488
|
+
const linearService = await createLinearService(rootOpts);
|
|
451
489
|
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
452
490
|
// Wrap valid issue identifiers as markdown links before creating, so the description
|
|
453
491
|
// saved on Linear has clickable refs from the start. Self-reference can't apply here
|
|
@@ -486,7 +524,7 @@ async function handleCreateIssue(title, options, command) {
|
|
|
486
524
|
// so only create separate attachment records for non-image files.
|
|
487
525
|
const attachments = [];
|
|
488
526
|
if (uploadResults.length > 0) {
|
|
489
|
-
const attachmentsService = createGraphQLAttachmentsService(rootOpts);
|
|
527
|
+
const attachmentsService = await createGraphQLAttachmentsService(rootOpts);
|
|
490
528
|
for (const uploadResult of uploadResults) {
|
|
491
529
|
if (!isImageFile(uploadResult.filename)) {
|
|
492
530
|
const attachment = await attachmentsService.createAttachment({
|
|
@@ -517,9 +555,9 @@ async function handleCreateIssue(title, options, command) {
|
|
|
517
555
|
outputSuccess(output);
|
|
518
556
|
}
|
|
519
557
|
async function handleHistoryIssue(issueId, _options, command) {
|
|
520
|
-
const rootOpts = command
|
|
521
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
522
|
-
const linearService = createLinearService(rootOpts);
|
|
558
|
+
const rootOpts = getRootOpts(command);
|
|
559
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
560
|
+
const linearService = await createLinearService(rootOpts);
|
|
523
561
|
const resolvedId = await linearService.resolveIssueId(issueId);
|
|
524
562
|
const result = await graphQLService.rawRequest(GET_ISSUE_STATE_HISTORY_QUERY, {
|
|
525
563
|
id: resolvedId,
|
|
@@ -547,9 +585,9 @@ async function handleHistoryIssue(issueId, _options, command) {
|
|
|
547
585
|
});
|
|
548
586
|
}
|
|
549
587
|
async function handleRelateIssue(issueId, options, command) {
|
|
550
|
-
const rootOpts = command
|
|
551
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
552
|
-
const linearService = createLinearService(rootOpts);
|
|
588
|
+
const rootOpts = getRootOpts(command);
|
|
589
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
590
|
+
const linearService = await createLinearService(rootOpts);
|
|
553
591
|
const sourceId = await linearService.resolveIssueId(issueId);
|
|
554
592
|
const relations = await createRelations(sourceId, options, graphQLService, linearService);
|
|
555
593
|
if (relations.length === 0) {
|
|
@@ -620,9 +658,9 @@ function buildRelationEntries(nodes, peerKey, direction, normalizeType) {
|
|
|
620
658
|
return entries;
|
|
621
659
|
}
|
|
622
660
|
async function handleRelatedIssues(issueId, _options, command) {
|
|
623
|
-
const rootOpts = command
|
|
624
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
625
|
-
const linearService = createLinearService(rootOpts);
|
|
661
|
+
const rootOpts = getRootOpts(command);
|
|
662
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
663
|
+
const linearService = await createLinearService(rootOpts);
|
|
626
664
|
const resolvedId = await linearService.resolveIssueId(issueId);
|
|
627
665
|
const result = await graphQLService.rawRequest(GET_ISSUE_RELATIONS_QUERY, {
|
|
628
666
|
id: resolvedId,
|
|
@@ -813,10 +851,10 @@ async function handleLinkReferencesBatch(teamInput, options, graphQLService, lin
|
|
|
813
851
|
},
|
|
814
852
|
});
|
|
815
853
|
}
|
|
816
|
-
function handleLinkReferencesIssue(issueIdOrTeamFlag, options, command) {
|
|
817
|
-
const rootOpts = command
|
|
818
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
819
|
-
const linearService = createLinearService(rootOpts);
|
|
854
|
+
async function handleLinkReferencesIssue(issueIdOrTeamFlag, options, command) {
|
|
855
|
+
const rootOpts = getRootOpts(command);
|
|
856
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
857
|
+
const linearService = await createLinearService(rootOpts);
|
|
820
858
|
if (options.team) {
|
|
821
859
|
if (issueIdOrTeamFlag) {
|
|
822
860
|
throw new Error("Provide either an issueId positional argument OR --team, not both. With --team, omit the issueId.");
|
|
@@ -832,20 +870,20 @@ function handleLinkReferencesIssue(issueIdOrTeamFlag, options, command) {
|
|
|
832
870
|
return handleLinkReferencesSingle(issueIdOrTeamFlag, options, graphQLService, linearService);
|
|
833
871
|
}
|
|
834
872
|
async function handleReadIssue(issueIds, _options, command) {
|
|
835
|
-
const rootOpts = command
|
|
836
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
837
|
-
const linearService = createLinearService(rootOpts);
|
|
873
|
+
const rootOpts = getRootOpts(command);
|
|
874
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
875
|
+
const linearService = await createLinearService(rootOpts);
|
|
838
876
|
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
839
|
-
const
|
|
877
|
+
const fileService = await createFileService(rootOpts);
|
|
840
878
|
if (issueIds.length === 1) {
|
|
841
879
|
const issue = await issuesService.getIssueById(issueIds[0]);
|
|
842
|
-
const resolved = await downloadLinearUploads(issue,
|
|
880
|
+
const resolved = await downloadLinearUploads(issue, fileService);
|
|
843
881
|
outputSuccess(resolved);
|
|
844
882
|
}
|
|
845
883
|
else {
|
|
846
884
|
const results = await Promise.all(issueIds.map(async (id) => {
|
|
847
885
|
const issue = await issuesService.getIssueById(id);
|
|
848
|
-
return downloadLinearUploads(issue,
|
|
886
|
+
return downloadLinearUploads(issue, fileService);
|
|
849
887
|
}));
|
|
850
888
|
outputSuccess(results);
|
|
851
889
|
}
|
|
@@ -863,9 +901,9 @@ async function handleUpdateIssue(issueId, options, command) {
|
|
|
863
901
|
options.description = readDescriptionFile(options.descriptionFile);
|
|
864
902
|
}
|
|
865
903
|
validateUpdateOptions(options);
|
|
866
|
-
const rootOpts = command
|
|
867
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
868
|
-
const linearService = createLinearService(rootOpts);
|
|
904
|
+
const rootOpts = getRootOpts(command);
|
|
905
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
906
|
+
const linearService = await createLinearService(rootOpts);
|
|
869
907
|
if (options.appendDescription) {
|
|
870
908
|
const resolved = await linearService.resolveIssueId(issueId);
|
|
871
909
|
const current = await graphQLService.rawRequest("query($id: String!) { issue(id: $id) { description } }", { id: resolved });
|
|
@@ -952,9 +990,9 @@ async function handleRetrolink(options, command) {
|
|
|
952
990
|
const description = commits.length > 0
|
|
953
991
|
? commits.map((msg) => `- ${msg}`).join("\n")
|
|
954
992
|
: `Retrolinked from branch: ${currentBranch}`;
|
|
955
|
-
const rootOpts = command
|
|
956
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
957
|
-
const linearService = createLinearService(rootOpts);
|
|
993
|
+
const rootOpts = getRootOpts(command);
|
|
994
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
995
|
+
const linearService = await createLinearService(rootOpts);
|
|
958
996
|
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
959
997
|
let result;
|
|
960
998
|
if (options.issue) {
|
|
@@ -1035,8 +1073,9 @@ export function setupIssuesCommands(program) {
|
|
|
1035
1073
|
.option("-t, --title <title>", "issue title (alternative to positional argument)")
|
|
1036
1074
|
.option("-d, --description <desc>", "issue description")
|
|
1037
1075
|
.option("--description-file <path>", "read description from file (use - for stdin)")
|
|
1076
|
+
.option("--template <name>", "use a named description template from config.descriptionTemplates")
|
|
1038
1077
|
.option("-a, --assignee <assignee>", "assign to user (name, alias, or UUID)")
|
|
1039
|
-
.option("-p, --priority <priority>", "priority: name (urgent/high/medium/low) or number (
|
|
1078
|
+
.option("-p, --priority <priority>", "priority: name (none/urgent/high/medium/normal/low) or number (0-4)")
|
|
1040
1079
|
.option("--project <project>", "add to project (name or ID)")
|
|
1041
1080
|
.option("--team <team>", "team key or name (default: from config)")
|
|
1042
1081
|
.option("--labels <labels>", "labels (comma-separated names, auto-resolved per team)")
|
|
@@ -1056,6 +1095,8 @@ export function setupIssuesCommands(program) {
|
|
|
1056
1095
|
.option("--checkout", "create and checkout a git branch named after the issue")
|
|
1057
1096
|
.option("--skip-validation", "skip all validation (labels, description, assignee, project)")
|
|
1058
1097
|
.option("--no-auto-link", "skip auto-linking issue references found in the description")
|
|
1098
|
+
.option("--footer <text>", "text appended to the description (overrides config.messageFooter)")
|
|
1099
|
+
.option("--no-footer", "skip the configured messageFooter for this issue")
|
|
1059
1100
|
.action(handleAsyncCommand((titleArg, options, command) => {
|
|
1060
1101
|
// Normalize --label alias to --labels
|
|
1061
1102
|
if (options.label && !options.labels) {
|
|
@@ -1087,7 +1128,7 @@ export function setupIssuesCommands(program) {
|
|
|
1087
1128
|
.option("--append-description <text>", "append text to the existing description")
|
|
1088
1129
|
.option("-s, --status <status>", "new status name or ID")
|
|
1089
1130
|
.option("--state <status>", "alias for --status (new status name or ID)")
|
|
1090
|
-
.option("-p, --priority <priority>", "new priority: name (urgent/high/medium/low) or number (
|
|
1131
|
+
.option("-p, --priority <priority>", "new priority: name (none/urgent/high/medium/normal/low) or number (0-4)")
|
|
1091
1132
|
.option("--assignee <assignee>", "new assignee (name, alias, or UUID)")
|
|
1092
1133
|
.option("--project <project>", "new project (name or ID)")
|
|
1093
1134
|
.option("--labels <labels>", "labels (comma-separated names or IDs)")
|