@enrichlayer/el-linear 1.9.0 → 1.15.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 +139 -10
- package/claude-skills/linear-operations/SKILL.md +41 -1
- package/dist/auth/linear-credential.d.ts +27 -0
- package/dist/auth/linear-credential.js +1 -0
- package/dist/auth/oauth-app-config.d.ts +4 -3
- package/dist/auth/oauth-app-config.js +13 -2
- package/dist/auth/oauth-callback.d.ts +2 -3
- package/dist/auth/oauth-callback.js +2 -2
- package/dist/auth/oauth-client.d.ts +8 -2
- package/dist/auth/oauth-client.js +26 -0
- package/dist/auth/oauth-fs.d.ts +2 -1
- package/dist/auth/oauth-headless.d.ts +2 -1
- package/dist/auth/oauth-storage.d.ts +5 -1
- package/dist/auth/oauth-storage.js +1 -1
- package/dist/auth/oauth-token.d.ts +4 -3
- package/dist/auth/oauth-token.js +16 -4
- package/dist/auth/token-resolver.d.ts +14 -5
- package/dist/auth/token-resolver.js +6 -1
- package/dist/commands/attachments.js +2 -1
- package/dist/commands/batch.js +18 -21
- package/dist/commands/comments.js +22 -33
- package/dist/commands/config.js +178 -5
- package/dist/commands/cycles.js +2 -1
- package/dist/commands/documents.js +2 -1
- package/dist/commands/graphql.js +4 -6
- package/dist/commands/init/aliases.js +1 -1
- package/dist/commands/init/defaults.d.ts +2 -1
- package/dist/commands/init/index.js +45 -35
- package/dist/commands/init/oauth.d.ts +4 -1
- package/dist/commands/init/oauth.js +22 -4
- package/dist/commands/init/shared.d.ts +24 -2
- package/dist/commands/init/shared.js +35 -4
- package/dist/commands/init/token.d.ts +3 -3
- package/dist/commands/init/token.js +5 -24
- package/dist/commands/init/workspace.d.ts +2 -1
- package/dist/commands/init/workspace.js +1 -1
- package/dist/commands/introspect.d.ts +27 -0
- package/dist/commands/introspect.js +178 -0
- package/dist/commands/issue-id.js +1 -3
- package/dist/commands/issues/branch.js +9 -1
- package/dist/commands/issues/description.js +2 -6
- package/dist/commands/issues/link-references.d.ts +21 -0
- package/dist/commands/issues/link-references.js +171 -0
- package/dist/commands/issues/relations.d.ts +44 -0
- package/dist/commands/issues/relations.js +132 -0
- package/dist/commands/issues.js +269 -309
- package/dist/commands/labels.js +15 -24
- package/dist/commands/profile.js +1 -0
- package/dist/commands/project-milestones.js +13 -20
- package/dist/commands/projects.d.ts +2 -0
- package/dist/commands/projects.js +157 -44
- package/dist/commands/read-shortcut.d.ts +1 -1
- package/dist/commands/read-shortcut.js +28 -8
- package/dist/commands/refs.js +75 -8
- package/dist/commands/releases.js +26 -30
- package/dist/commands/search.js +49 -33
- package/dist/commands/teams.js +2 -1
- package/dist/commands/templates.js +9 -14
- package/dist/commands/users.js +5 -2
- package/dist/config/config.d.ts +99 -1
- package/dist/config/config.js +264 -52
- package/dist/config/error-enrichment.d.ts +62 -0
- package/dist/config/error-enrichment.js +417 -0
- package/dist/config/issue-validation.d.ts +37 -0
- package/dist/config/issue-validation.js +63 -1
- package/dist/config/paths.d.ts +2 -8
- package/dist/config/paths.js +4 -2
- package/dist/config/resolver.d.ts +8 -1
- package/dist/config/resolver.js +11 -5
- package/dist/main.js +13 -1
- package/dist/queries/attachments-types.d.ts +30 -0
- package/dist/queries/attachments-types.js +5 -0
- package/dist/queries/comments-types.d.ts +55 -0
- package/dist/queries/comments-types.js +5 -0
- package/dist/queries/common.d.ts +2 -2
- package/dist/queries/common.js +8 -0
- package/dist/queries/documents-types.d.ts +62 -0
- package/dist/queries/documents-types.js +9 -0
- package/dist/queries/introspect-types.d.ts +58 -0
- package/dist/queries/introspect-types.js +10 -0
- package/dist/queries/issues-types.d.ts +481 -0
- package/dist/queries/issues-types.js +23 -0
- package/dist/queries/issues.d.ts +51 -10
- package/dist/queries/issues.js +147 -5
- package/dist/queries/labels-types.d.ts +65 -0
- package/dist/queries/labels-types.js +5 -0
- package/dist/queries/project-milestones-types.d.ts +92 -0
- package/dist/queries/project-milestones-types.js +10 -0
- package/dist/queries/project-milestones.d.ts +1 -1
- package/dist/queries/projects-types.d.ts +76 -0
- package/dist/queries/projects-types.js +5 -0
- package/dist/queries/projects.d.ts +2 -0
- package/dist/queries/projects.js +22 -0
- package/dist/queries/releases-types.d.ts +85 -0
- package/dist/queries/releases-types.js +5 -0
- package/dist/queries/search-types.d.ts +102 -0
- package/dist/queries/search-types.js +6 -0
- package/dist/queries/templates-types.d.ts +62 -0
- package/dist/queries/templates-types.js +9 -0
- package/dist/types/linear.d.ts +21 -3
- package/dist/utils/auto-link-references.d.ts +3 -3
- package/dist/utils/auto-link-references.js +30 -34
- package/dist/utils/extract-field.d.ts +19 -0
- package/dist/utils/extract-field.js +99 -0
- package/dist/utils/file-service.d.ts +6 -13
- package/dist/utils/file-service.js +0 -2
- package/dist/utils/formatters/summary.js +6 -1
- package/dist/utils/graphql-attachments-service.js +6 -9
- package/dist/utils/graphql-documents-service.js +19 -25
- package/dist/utils/graphql-issues-service.d.ts +112 -46
- package/dist/utils/graphql-issues-service.js +398 -206
- package/dist/utils/graphql-service.d.ts +10 -12
- package/dist/utils/graphql-service.js +0 -3
- package/dist/utils/issue-reference-extractor.d.ts +7 -0
- package/dist/utils/issue-reference-extractor.js +5 -3
- package/dist/utils/issues-service-bootstrap.d.ts +28 -0
- package/dist/utils/issues-service-bootstrap.js +27 -0
- package/dist/utils/linear-service.d.ts +21 -14
- package/dist/utils/linear-service.js +73 -11
- package/dist/utils/markdown-prosemirror.js +12 -12
- package/dist/utils/mention-resolver.js +1 -1
- package/dist/utils/output.d.ts +82 -2
- package/dist/utils/output.js +76 -11
- package/dist/utils/project-slug.d.ts +21 -0
- package/dist/utils/project-slug.js +45 -0
- package/dist/utils/protected-ranges.d.ts +14 -0
- package/dist/utils/protected-ranges.js +88 -2
- package/dist/utils/sanitize-for-log.d.ts +24 -0
- package/dist/utils/sanitize-for-log.js +38 -0
- package/dist/utils/table-formatter.js +24 -0
- package/dist/utils/validators.d.ts +7 -2
- package/dist/utils/validators.js +6 -0
- package/dist/utils/workspace-url.d.ts +5 -1
- package/dist/utils/workspace-url.js +53 -7
- package/package.json +2 -2
|
@@ -14,10 +14,11 @@
|
|
|
14
14
|
* Skip is the default at every prompt. Only `init token` is required for a
|
|
15
15
|
* first-time setup; everything else can be skipped and revisited later.
|
|
16
16
|
*/
|
|
17
|
+
import { validateOAuthActor, } from "../../auth/oauth-client.js";
|
|
17
18
|
import { mergeAliasesIntoConfig, runAliasesImport, runAliasesStep, } from "./aliases.js";
|
|
18
19
|
import { runDefaultsStep } from "./defaults.js";
|
|
19
20
|
import { runOAuthRevoke, runOAuthStep } from "./oauth.js";
|
|
20
|
-
import { assignDefined, printStep, readConfig,
|
|
21
|
+
import { assignDefined, printStep, readConfig, updateConfig, } from "./shared.js";
|
|
21
22
|
import { runTokenStep } from "./token.js";
|
|
22
23
|
import { runWorkspaceStep } from "./workspace.js";
|
|
23
24
|
/**
|
|
@@ -60,6 +61,7 @@ export function setupInitCommands(program) {
|
|
|
60
61
|
.command("oauth")
|
|
61
62
|
.description("Authorize via OAuth 2.0 (PKCE) — alternative to a personal API token")
|
|
62
63
|
.option("--force", "ignore existing tokens; re-authorize unconditionally")
|
|
64
|
+
.option("--actor <actor>", "OAuth actor: user (default) or app for agents/service accounts", validateOAuthActor)
|
|
63
65
|
.option("--revoke", "revoke and remove the stored OAuth tokens")
|
|
64
66
|
.option("--no-browser", "skip the browser-open + localhost listener; paste the code manually")
|
|
65
67
|
.option("--port <port>", "localhost callback port (default 8765)", (value) => Number.parseInt(value, 10))
|
|
@@ -72,6 +74,7 @@ export function setupInitCommands(program) {
|
|
|
72
74
|
return;
|
|
73
75
|
}
|
|
74
76
|
await runOAuthStep({
|
|
77
|
+
actor: options.actor,
|
|
75
78
|
force: options.force ?? false,
|
|
76
79
|
// commander's `--no-browser` produces `browser: false`.
|
|
77
80
|
noBrowser: options.browser === false,
|
|
@@ -87,14 +90,15 @@ export function setupInitCommands(program) {
|
|
|
87
90
|
const existing = await readConfig();
|
|
88
91
|
printStep("workspace", "Workspace defaults");
|
|
89
92
|
const ws = await runWorkspaceStep(tokenResult.token, tokenResult.viewer.organization.urlKey, existing);
|
|
90
|
-
|
|
93
|
+
// Re-read inside the lock so a concurrent `init <step>` that
|
|
94
|
+
// finished while we were prompting isn't clobbered (DEV-4066).
|
|
95
|
+
await updateConfig((current) => assignDefined(current, {
|
|
91
96
|
defaultTeam: ws.defaultTeam,
|
|
92
|
-
teams: { ...(
|
|
93
|
-
// Don't clobber a manual urlKey override — same
|
|
94
|
-
// rule as the full wizard.
|
|
95
|
-
workspaceUrlKey:
|
|
96
|
-
});
|
|
97
|
-
await writeConfig(merged);
|
|
97
|
+
teams: { ...(current.teams ?? {}), ...ws.teams },
|
|
98
|
+
// Don't clobber a manual urlKey override — same
|
|
99
|
+
// idempotency rule as the full wizard.
|
|
100
|
+
workspaceUrlKey: current.workspaceUrlKey ?? ws.workspaceUrlKey,
|
|
101
|
+
}));
|
|
98
102
|
console.log(" ✓ Workspace defaults saved.");
|
|
99
103
|
}));
|
|
100
104
|
init
|
|
@@ -123,8 +127,9 @@ export function setupInitCommands(program) {
|
|
|
123
127
|
console.log(" No alias changes — config unchanged.");
|
|
124
128
|
return;
|
|
125
129
|
}
|
|
126
|
-
|
|
127
|
-
|
|
130
|
+
// Re-merge under the lock against the latest on-disk state so
|
|
131
|
+
// a concurrent step's write isn't lost (DEV-4066).
|
|
132
|
+
await updateConfig((current) => mergeAliasesIntoConfig(current, updates));
|
|
128
133
|
console.log(` ✓ Updated aliases for ${updates.size} user(s).`);
|
|
129
134
|
}));
|
|
130
135
|
init
|
|
@@ -134,15 +139,16 @@ export function setupInitCommands(program) {
|
|
|
134
139
|
const existing = await readConfig();
|
|
135
140
|
printStep("defaults", "Defaults");
|
|
136
141
|
const result = await runDefaultsStep(existing);
|
|
137
|
-
|
|
142
|
+
// Re-read inside the lock so a concurrent `init <step>` that
|
|
143
|
+
// finished while we were prompting isn't clobbered (DEV-4066).
|
|
144
|
+
await updateConfig((current) => assignDefined(current, {
|
|
138
145
|
defaultLabels: result.defaultLabels,
|
|
139
146
|
defaultAssignee: result.defaultAssignee,
|
|
140
147
|
defaultPriority: result.defaultPriority,
|
|
141
148
|
statusDefaults: result.statusDefaults,
|
|
142
149
|
terms: result.terms,
|
|
143
150
|
cacheTTLSeconds: result.cacheTTLSeconds,
|
|
144
|
-
});
|
|
145
|
-
await writeConfig(merged);
|
|
151
|
+
}));
|
|
146
152
|
console.log(" ✓ Defaults saved.");
|
|
147
153
|
}));
|
|
148
154
|
}
|
|
@@ -170,33 +176,37 @@ async function runFullWizardImpl(options) {
|
|
|
170
176
|
// Step 4: defaults
|
|
171
177
|
printStep("4/4", "Defaults");
|
|
172
178
|
const defaults = await runDefaultsStep(existing);
|
|
173
|
-
// Merge everything and write atomically at the end
|
|
179
|
+
// Merge everything and write atomically at the end, under the config
|
|
180
|
+
// lock so a parallel `init <step>` invocation can't clobber us
|
|
181
|
+
// (DEV-4066). Re-read inside the lock to merge on the latest state.
|
|
174
182
|
// Idempotency rule: only write a key when the user explicitly changed it.
|
|
175
|
-
// `
|
|
183
|
+
// `current.X ?? new.X` preserves any manual override the user may have
|
|
176
184
|
// in config.json (self-hosted Linear urlKey, custom default team, etc.).
|
|
177
185
|
// `assignDefined` skips undefined values so the resulting object's own-
|
|
178
186
|
// property set matches what JSON.stringify would actually serialize.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
187
|
+
await updateConfig((current) => {
|
|
188
|
+
let merged = assignDefined(current, {
|
|
189
|
+
// defaults step: result is `existing.X` itself when the user
|
|
190
|
+
// skipped the edit branch, so direct assignment is safe.
|
|
191
|
+
defaultLabels: defaults.defaultLabels,
|
|
192
|
+
defaultAssignee: defaults.defaultAssignee,
|
|
193
|
+
defaultPriority: defaults.defaultPriority,
|
|
194
|
+
statusDefaults: defaults.statusDefaults,
|
|
195
|
+
terms: defaults.terms,
|
|
196
|
+
cacheTTLSeconds: defaults.cacheTTLSeconds,
|
|
197
|
+
// workspace step: `ws.defaultTeam` may be the existing value (user
|
|
198
|
+
// skipped) or a new pick.
|
|
199
|
+
defaultTeam: ws.defaultTeam,
|
|
200
|
+
// Always merge the team UUID cache (additive, not destructive).
|
|
201
|
+
teams: { ...(current.teams ?? {}), ...ws.teams },
|
|
202
|
+
// workspaceUrlKey: never clobber an existing manual override.
|
|
203
|
+
workspaceUrlKey: current.workspaceUrlKey ?? ws.workspaceUrlKey,
|
|
204
|
+
});
|
|
205
|
+
if (aliasUpdates.size > 0) {
|
|
206
|
+
merged = mergeAliasesIntoConfig(merged, aliasUpdates);
|
|
207
|
+
}
|
|
208
|
+
return merged;
|
|
195
209
|
});
|
|
196
|
-
if (aliasUpdates.size > 0) {
|
|
197
|
-
merged = mergeAliasesIntoConfig(merged, aliasUpdates);
|
|
198
|
-
}
|
|
199
|
-
await writeConfig(merged);
|
|
200
210
|
console.log("\n✓ Setup complete.");
|
|
201
211
|
console.log(" Token: ~/.config/el-linear/token (mode 0600)");
|
|
202
212
|
console.log(" Config: ~/.config/el-linear/config.json");
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* revoke before doing anything else.
|
|
19
19
|
*/
|
|
20
20
|
import { runLocalhostCallback } from "../../auth/oauth-callback.js";
|
|
21
|
+
import { type OAuthActor } from "../../auth/oauth-client.js";
|
|
21
22
|
import { type OAuthState } from "../../auth/oauth-storage.js";
|
|
22
23
|
import { type FetchLike } from "../../auth/oauth-token.js";
|
|
23
24
|
interface ViewerResponse {
|
|
@@ -33,6 +34,8 @@ interface ViewerResponse {
|
|
|
33
34
|
};
|
|
34
35
|
}
|
|
35
36
|
export interface OAuthStepOptions {
|
|
37
|
+
/** OAuth actor mode: `user` (default) or `app` for agents/service accounts. */
|
|
38
|
+
actor?: OAuthActor;
|
|
36
39
|
/** Force re-authorization even if existing state is valid. */
|
|
37
40
|
force?: boolean;
|
|
38
41
|
/** Skip the localhost listener; use the headless code-paste prompt. */
|
|
@@ -56,7 +59,7 @@ export interface OAuthStepOptions {
|
|
|
56
59
|
/** Test seam for `viewer` validation against the new bearer token. */
|
|
57
60
|
validateViewer?: (oauthToken: string) => Promise<ViewerResponse["viewer"]>;
|
|
58
61
|
}
|
|
59
|
-
|
|
62
|
+
interface OAuthStepResult {
|
|
60
63
|
state: OAuthState;
|
|
61
64
|
viewer: ViewerResponse["viewer"];
|
|
62
65
|
}
|
|
@@ -21,7 +21,7 @@ import { spawn } from "node:child_process";
|
|
|
21
21
|
import { checkbox, input, password, select } from "@inquirer/prompts";
|
|
22
22
|
import { readTeamOAuthConfig } from "../../auth/oauth-app-config.js";
|
|
23
23
|
import { DEFAULT_CALLBACK_PATH, runLocalhostCallback, } from "../../auth/oauth-callback.js";
|
|
24
|
-
import { ALL_SCOPES, buildAuthorizeUrl, DEFAULT_SCOPES, generatePkce, generateState, SCOPE_DESCRIPTIONS, validateScopes, } from "../../auth/oauth-client.js";
|
|
24
|
+
import { ALL_SCOPES, buildAuthorizeUrl, DEFAULT_SCOPES, generatePkce, generateState, SCOPE_DESCRIPTIONS, validateActorScopes, validateScopes, } from "../../auth/oauth-client.js";
|
|
25
25
|
import { promptForPastedCode } from "../../auth/oauth-headless.js";
|
|
26
26
|
import { clearOAuthState, OAUTH_STATE_VERSION, readOAuthState, writeOAuthState, } from "../../auth/oauth-storage.js";
|
|
27
27
|
import { exchangeCodeForTokens, revokeToken, } from "../../auth/oauth-token.js";
|
|
@@ -128,7 +128,11 @@ async function promptRegistration(defaults) {
|
|
|
128
128
|
logLine("");
|
|
129
129
|
logLine(TS(`Register a Linear OAuth app: ${REGISTRATION_URL}`));
|
|
130
130
|
logLine(TS(`Set the redirect URL to: http://localhost:${defaults.port ?? DEFAULT_PORT}${DEFAULT_CALLBACK_PATH}`));
|
|
131
|
-
logLine(TS(
|
|
131
|
+
logLine(TS(`Then paste the client_id (and client_secret, if your app is configured as confidential).`));
|
|
132
|
+
logLine(TS(`Actor: ${defaults.actor}.`));
|
|
133
|
+
if (defaults.actor === "user") {
|
|
134
|
+
logLine(TS("Use --actor app for agent/service-account app user tokens."));
|
|
135
|
+
}
|
|
132
136
|
logLine("");
|
|
133
137
|
const port = Number.parseInt(await input({
|
|
134
138
|
message: "Localhost callback port:",
|
|
@@ -159,24 +163,34 @@ async function promptRegistration(defaults) {
|
|
|
159
163
|
})),
|
|
160
164
|
validate: (selections) => selections.length > 0 || "Pick at least one scope",
|
|
161
165
|
}));
|
|
166
|
+
const validatedScopes = validateScopes(scopes);
|
|
167
|
+
validateActorScopes(defaults.actor, validatedScopes);
|
|
162
168
|
return {
|
|
169
|
+
actor: defaults.actor,
|
|
163
170
|
clientId,
|
|
164
171
|
clientSecret: clientSecret || undefined,
|
|
165
172
|
port,
|
|
166
|
-
scopes:
|
|
173
|
+
scopes: validatedScopes,
|
|
167
174
|
};
|
|
168
175
|
}
|
|
169
176
|
async function resolveRegistration(defaults) {
|
|
170
177
|
const teamConfig = await readTeamOAuthConfig();
|
|
171
178
|
if (!teamConfig) {
|
|
172
|
-
return promptRegistration({
|
|
179
|
+
return promptRegistration({
|
|
180
|
+
actor: defaults.actor ?? "user",
|
|
181
|
+
port: defaults.manualPort,
|
|
182
|
+
});
|
|
173
183
|
}
|
|
184
|
+
const actor = defaults.actor ?? teamConfig.actor;
|
|
174
185
|
const port = defaults.requestedPort ?? teamConfig.redirectPort;
|
|
175
186
|
logLine("");
|
|
176
187
|
logLine(TS(`Using Linear OAuth app defaults from ${teamConfig.sourcePath}.`));
|
|
188
|
+
logLine(TS(`Actor: ${actor}.`));
|
|
177
189
|
logLine(TS(`Callback URL: http://localhost:${port}${DEFAULT_CALLBACK_PATH}`));
|
|
178
190
|
logLine("");
|
|
191
|
+
validateActorScopes(actor, teamConfig.scopes);
|
|
179
192
|
return {
|
|
193
|
+
actor,
|
|
180
194
|
clientId: teamConfig.clientId,
|
|
181
195
|
port,
|
|
182
196
|
scopes: teamConfig.scopes,
|
|
@@ -231,6 +245,7 @@ export async function runOAuthStep(options = {}) {
|
|
|
231
245
|
// Both `reauth` and `revoked` fall through to the re-auth flow.
|
|
232
246
|
}
|
|
233
247
|
const reg = await resolveRegistration({
|
|
248
|
+
actor: options.actor,
|
|
234
249
|
manualPort: options.port ?? extractPortFromRedirect(existing) ?? DEFAULT_PORT,
|
|
235
250
|
requestedPort: options.port,
|
|
236
251
|
});
|
|
@@ -243,6 +258,7 @@ export async function runOAuthStep(options = {}) {
|
|
|
243
258
|
scopes: reg.scopes,
|
|
244
259
|
state,
|
|
245
260
|
codeChallenge: pkce.challenge,
|
|
261
|
+
actor: reg.actor,
|
|
246
262
|
});
|
|
247
263
|
logLine("");
|
|
248
264
|
logLine(TS("Opening your browser to authorize…"));
|
|
@@ -292,6 +308,7 @@ export async function runOAuthStep(options = {}) {
|
|
|
292
308
|
}, options.fetchImpl);
|
|
293
309
|
const newState = {
|
|
294
310
|
v: OAUTH_STATE_VERSION,
|
|
311
|
+
actor: reg.actor,
|
|
295
312
|
clientId: reg.clientId,
|
|
296
313
|
clientSecret: reg.clientSecret,
|
|
297
314
|
registeredRedirectUri: redirectUri,
|
|
@@ -304,6 +321,7 @@ export async function runOAuthStep(options = {}) {
|
|
|
304
321
|
};
|
|
305
322
|
logLine(TS("Validating against viewer…"));
|
|
306
323
|
const viewer = await validateViewer(newState.accessToken);
|
|
324
|
+
newState.viewerId = viewer.id;
|
|
307
325
|
await writeOAuthState(newState);
|
|
308
326
|
logLine(TS(`✓ Authorized as ${viewer.displayName} <${viewer.email}> (${viewer.organization.name}).`));
|
|
309
327
|
return { state: newState, viewer };
|
|
@@ -29,8 +29,30 @@ type DeepPartial<T> = T extends Array<infer _U> ? T : T extends object ? {
|
|
|
29
29
|
*/
|
|
30
30
|
export type WizardConfig = DeepPartial<ElLinearConfig>;
|
|
31
31
|
export declare function ensureConfigDir(): Promise<void>;
|
|
32
|
-
export declare function readConfig(): Promise<WizardConfig>;
|
|
33
|
-
export declare function writeConfig(config: WizardConfig): Promise<void>;
|
|
32
|
+
export declare function readConfig(configPath?: string): Promise<WizardConfig>;
|
|
33
|
+
export declare function writeConfig(config: WizardConfig, configPath?: string): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Run a read-modify-write update on the active profile's config.json under
|
|
36
|
+
* an exclusive file lock. The mutator receives the latest on-disk config
|
|
37
|
+
* (re-read inside the lock), and its return value is written back atomically.
|
|
38
|
+
*
|
|
39
|
+
* Use this anywhere two parallel wizard invocations could race a
|
|
40
|
+
* read → mutate → write sequence. Each `el-linear init <step>` re-reads the
|
|
41
|
+
* config and merges its slice; without serialization, the slower writer's
|
|
42
|
+
* mutation would clobber the faster writer's already-persisted changes.
|
|
43
|
+
*
|
|
44
|
+
* The interactive prompt phase MUST run outside the lock — prompts can sit
|
|
45
|
+
* waiting for user input longer than the lock's stale window. Seed prompts
|
|
46
|
+
* with a cheap pre-read, do the prompts, then call `updateConfig` with a
|
|
47
|
+
* mutator that re-reads and merges your slice on top of the latest state.
|
|
48
|
+
*
|
|
49
|
+
* The active-profile path is snapshotted ONCE at entry and threaded through
|
|
50
|
+
* `readConfig` + `writeConfig`. A theoretical mid-update profile switch
|
|
51
|
+
* (`--profile` is bound by the commander preAction before any subcommand
|
|
52
|
+
* runs, so this can't happen via the CLI today) can't cause a lock-A /
|
|
53
|
+
* read-or-write-B mismatch.
|
|
54
|
+
*/
|
|
55
|
+
export declare function updateConfig(mutator: (current: WizardConfig) => WizardConfig | Promise<WizardConfig>): Promise<void>;
|
|
34
56
|
export declare function readToken(): Promise<string | null>;
|
|
35
57
|
/**
|
|
36
58
|
* Write the token to disk with mode 0600.
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { randomBytes } from "node:crypto";
|
|
9
9
|
import fs from "node:fs/promises";
|
|
10
10
|
import path from "node:path";
|
|
11
|
+
import { withFileLock } from "../../auth/oauth-fs.js";
|
|
11
12
|
import { ALIASES_PROGRESS_PATH, CONFIG_DIR, CONFIG_PATH, resolveActiveProfile, TOKEN_PATH, } from "../../config/paths.js";
|
|
12
13
|
// Re-export for tests and call sites that already pulled the paths from here.
|
|
13
14
|
export { ALIASES_PROGRESS_PATH, CONFIG_PATH, TOKEN_PATH };
|
|
@@ -65,9 +66,9 @@ export async function ensureConfigDir() {
|
|
|
65
66
|
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
|
-
export async function readConfig() {
|
|
69
|
+
export async function readConfig(configPath = activePaths().configPath) {
|
|
69
70
|
try {
|
|
70
|
-
const raw = await fs.readFile(
|
|
71
|
+
const raw = await fs.readFile(configPath, "utf8");
|
|
71
72
|
return JSON.parse(raw);
|
|
72
73
|
}
|
|
73
74
|
catch (err) {
|
|
@@ -77,11 +78,41 @@ export async function readConfig() {
|
|
|
77
78
|
throw err;
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
|
-
export async function writeConfig(config) {
|
|
81
|
+
export async function writeConfig(config, configPath = activePaths().configPath) {
|
|
81
82
|
await ensureConfigDir();
|
|
82
83
|
// Stable key order so byte-identical config produces byte-identical output.
|
|
83
84
|
const sorted = sortKeys(config);
|
|
84
|
-
await atomicWrite(
|
|
85
|
+
await atomicWrite(configPath, `${JSON.stringify(sorted, null, 2)}\n`, 0o644);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Run a read-modify-write update on the active profile's config.json under
|
|
89
|
+
* an exclusive file lock. The mutator receives the latest on-disk config
|
|
90
|
+
* (re-read inside the lock), and its return value is written back atomically.
|
|
91
|
+
*
|
|
92
|
+
* Use this anywhere two parallel wizard invocations could race a
|
|
93
|
+
* read → mutate → write sequence. Each `el-linear init <step>` re-reads the
|
|
94
|
+
* config and merges its slice; without serialization, the slower writer's
|
|
95
|
+
* mutation would clobber the faster writer's already-persisted changes.
|
|
96
|
+
*
|
|
97
|
+
* The interactive prompt phase MUST run outside the lock — prompts can sit
|
|
98
|
+
* waiting for user input longer than the lock's stale window. Seed prompts
|
|
99
|
+
* with a cheap pre-read, do the prompts, then call `updateConfig` with a
|
|
100
|
+
* mutator that re-reads and merges your slice on top of the latest state.
|
|
101
|
+
*
|
|
102
|
+
* The active-profile path is snapshotted ONCE at entry and threaded through
|
|
103
|
+
* `readConfig` + `writeConfig`. A theoretical mid-update profile switch
|
|
104
|
+
* (`--profile` is bound by the commander preAction before any subcommand
|
|
105
|
+
* runs, so this can't happen via the CLI today) can't cause a lock-A /
|
|
106
|
+
* read-or-write-B mismatch.
|
|
107
|
+
*/
|
|
108
|
+
export async function updateConfig(mutator) {
|
|
109
|
+
await ensureConfigDir();
|
|
110
|
+
const configPath = activePaths().configPath;
|
|
111
|
+
await withFileLock(configPath, async () => {
|
|
112
|
+
const current = await readConfig(configPath);
|
|
113
|
+
const next = await mutator(current);
|
|
114
|
+
await writeConfig(next, configPath);
|
|
115
|
+
});
|
|
85
116
|
}
|
|
86
117
|
export async function readToken() {
|
|
87
118
|
try {
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* before saving. Token is stored at ~/.config/el-linear/token (mode 0600),
|
|
6
6
|
* never embedded in config.json.
|
|
7
7
|
*/
|
|
8
|
+
import { sanitizeForLog } from "../../utils/sanitize-for-log.js";
|
|
9
|
+
export { sanitizeForLog };
|
|
8
10
|
interface ViewerResponse {
|
|
9
11
|
viewer: {
|
|
10
12
|
id: string;
|
|
@@ -17,11 +19,10 @@ interface ViewerResponse {
|
|
|
17
19
|
};
|
|
18
20
|
};
|
|
19
21
|
}
|
|
20
|
-
|
|
22
|
+
interface TokenStepResult {
|
|
21
23
|
token: string;
|
|
22
24
|
viewer: ViewerResponse["viewer"];
|
|
23
25
|
}
|
|
24
|
-
export declare function sanitizeForLog(text: string): string;
|
|
25
26
|
/**
|
|
26
27
|
* Validate a Linear API token by fetching the viewer. Throws with a
|
|
27
28
|
* sanitized user-readable message on auth failure — the error string is
|
|
@@ -39,4 +40,3 @@ export declare function runTokenStep(options?: {
|
|
|
39
40
|
/** Skip the "replace existing?" prompt; always replace if existing is present. */
|
|
40
41
|
force?: boolean;
|
|
41
42
|
}): Promise<TokenStepResult>;
|
|
42
|
-
export {};
|
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { confirm, password } from "@inquirer/prompts";
|
|
9
9
|
import { GraphQLService } from "../../utils/graphql-service.js";
|
|
10
|
+
import { sanitizeForLog } from "../../utils/sanitize-for-log.js";
|
|
10
11
|
import { readToken, writeToken } from "./shared.js";
|
|
12
|
+
// Re-export for legacy import paths under `init/`. New code should import
|
|
13
|
+
// from `utils/sanitize-for-log.js` directly.
|
|
14
|
+
export { sanitizeForLog };
|
|
11
15
|
const TOKEN_GENERATION_URL = "https://linear.app/settings/account/security";
|
|
12
16
|
const VIEWER_QUERY = /* GraphQL */ `
|
|
13
17
|
query {
|
|
@@ -23,29 +27,6 @@ const VIEWER_QUERY = /* GraphQL */ `
|
|
|
23
27
|
}
|
|
24
28
|
}
|
|
25
29
|
`;
|
|
26
|
-
/**
|
|
27
|
-
* Strip anything that looks like a Linear API token from a string. Defense in
|
|
28
|
-
* depth: today the @linear/sdk error message embeds {query, variables} but not
|
|
29
|
-
* the Authorization header. A future SDK upgrade that includes headers (which
|
|
30
|
-
* upstream graphql-request has done historically) would otherwise silently
|
|
31
|
-
* write `Bearer lin_api_…` into stdout / shell history / CI logs. The regex
|
|
32
|
-
* also catches token shapes that may show up in custom error wrappers.
|
|
33
|
-
*/
|
|
34
|
-
// Personal-API tokens (`lin_api_…`) and OAuth access/refresh tokens
|
|
35
|
-
// (`lin_oauth_…`). Pre-fix the regex only matched personal tokens.
|
|
36
|
-
const TOKEN_PREFIX_RE = /lin_(api|oauth)_[A-Za-z0-9_-]{16,}/g;
|
|
37
|
-
// High-entropy bearer payload fallback: catches generic Bearer-style
|
|
38
|
-
// strings adjacent to Authorization / Bearer keywords. Useful for
|
|
39
|
-
// future SDK error wrappers that might leak headers without the
|
|
40
|
-
// `lin_` prefix.
|
|
41
|
-
const BEARER_PAYLOAD_RE = /(\b(?:Authorization|Bearer)\b[:\s]*)([A-Za-z0-9_\-/+=]{40,})/gi;
|
|
42
|
-
export function sanitizeForLog(text) {
|
|
43
|
-
return text
|
|
44
|
-
.replace(TOKEN_PREFIX_RE, (m) => m.startsWith("lin_oauth_")
|
|
45
|
-
? "lin_oauth_***REDACTED***"
|
|
46
|
-
: "lin_api_***REDACTED***")
|
|
47
|
-
.replace(BEARER_PAYLOAD_RE, "$1***REDACTED***");
|
|
48
|
-
}
|
|
49
30
|
/**
|
|
50
31
|
* Strict shape check on the viewer response. Treats whitespace-only fields as
|
|
51
32
|
* "validated to nothing" — easy to forge with a malformed but truthy stub
|
|
@@ -73,7 +54,7 @@ function viewerIsValid(viewer) {
|
|
|
73
54
|
* is redacted before it hits stdout.
|
|
74
55
|
*/
|
|
75
56
|
export async function validateToken(token) {
|
|
76
|
-
const service = new GraphQLService(token);
|
|
57
|
+
const service = new GraphQLService({ apiKey: token });
|
|
77
58
|
let data;
|
|
78
59
|
try {
|
|
79
60
|
data = await service.rawRequest(VIEWER_QUERY);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Lets the user pick a default team, optional. Skip-by-default.
|
|
6
6
|
*/
|
|
7
7
|
import type { WizardConfig } from "./shared.js";
|
|
8
|
-
|
|
8
|
+
interface WorkspaceStepResult {
|
|
9
9
|
workspaceUrlKey: string;
|
|
10
10
|
defaultTeam: string | undefined;
|
|
11
11
|
teams: Record<string, string>;
|
|
@@ -18,3 +18,4 @@ export interface WorkspaceStepResult {
|
|
|
18
18
|
* the existing config exactly.
|
|
19
19
|
*/
|
|
20
20
|
export declare function runWorkspaceStep(token: string, workspaceUrlKey: string, existing: WizardConfig): Promise<WorkspaceStepResult>;
|
|
21
|
+
export {};
|
|
@@ -25,7 +25,7 @@ const TEAMS_QUERY = /* GraphQL */ `
|
|
|
25
25
|
* the existing config exactly.
|
|
26
26
|
*/
|
|
27
27
|
export async function runWorkspaceStep(token, workspaceUrlKey, existing) {
|
|
28
|
-
const service = new GraphQLService(token);
|
|
28
|
+
const service = new GraphQLService({ apiKey: token });
|
|
29
29
|
// Fetch teams once so we can populate both the picker and the cached id map.
|
|
30
30
|
const data = await service.rawRequest(TEAMS_QUERY);
|
|
31
31
|
const teams = data?.teams?.nodes ?? [];
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `el-linear introspect` and `el-linear validate-flag` — expose the
|
|
3
|
+
* commander command tree so external linters (e.g. CI steps that scan
|
|
4
|
+
* SKILL.md files for `el-linear <cmd> --<flag>` references) can verify
|
|
5
|
+
* that hardcoded flag names actually exist on the current binary.
|
|
6
|
+
*
|
|
7
|
+
* Without this, a flag rename inside `el-linear` (say `--parent-ticket`
|
|
8
|
+
* → `--parent`) silently breaks every skill that prose-references the
|
|
9
|
+
* old name. Skills then fail at runtime, far from the rename commit.
|
|
10
|
+
*
|
|
11
|
+
* `introspect` dumps the full tree — name, description, version,
|
|
12
|
+
* options, aliases, and recursively nested subcommands.
|
|
13
|
+
*
|
|
14
|
+
* `validate-flag` is a thin ergonomic wrapper for the CI-lint use case:
|
|
15
|
+
*
|
|
16
|
+
* $ el-linear validate-flag issues create --parent-ticket
|
|
17
|
+
* { "ok": true, "command": ["issues", "create"], "flag": "--parent-ticket" }
|
|
18
|
+
* $ el-linear validate-flag issues create --does-not-exist
|
|
19
|
+
* { "ok": false, ... "error": "..." } # exit code 1
|
|
20
|
+
*
|
|
21
|
+
* Both commands ignore root-level flags (`--api-token`, `--json`, etc.)
|
|
22
|
+
* when walking subcommand options; those are dumped at the root, not
|
|
23
|
+
* under every command. `validate-flag` walks ancestors so a global flag
|
|
24
|
+
* (e.g. `--format`) validates on any subcommand.
|
|
25
|
+
*/
|
|
26
|
+
import type { Command } from "commander";
|
|
27
|
+
export declare function setupIntrospectCommand(program: Command): void;
|