@enrichlayer/el-linear 1.10.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 +126 -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/batch.js +18 -21
- package/dist/commands/comments.js +5 -5
- package/dist/commands/config.js +178 -5
- 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/issues/branch.js +9 -1
- package/dist/commands/issues/relations.d.ts +3 -14
- package/dist/commands/issues/relations.js +3 -3
- package/dist/commands/issues.js +222 -43
- package/dist/commands/labels.js +2 -1
- package/dist/commands/profile.js +1 -0
- package/dist/commands/projects.d.ts +2 -0
- package/dist/commands/projects.js +91 -7
- package/dist/commands/read-shortcut.d.ts +1 -1
- package/dist/commands/read-shortcut.js +28 -8
- package/dist/commands/refs.js +67 -8
- package/dist/commands/search.js +30 -5
- package/dist/commands/users.js +4 -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 +9 -2
- package/dist/main.js +13 -1
- package/dist/queries/comments-types.d.ts +15 -9
- package/dist/queries/common.d.ts +2 -2
- package/dist/queries/common.js +8 -0
- package/dist/queries/documents-types.d.ts +4 -3
- package/dist/queries/introspect-types.d.ts +8 -7
- package/dist/queries/issues-types.d.ts +92 -27
- package/dist/queries/issues.d.ts +49 -10
- package/dist/queries/issues.js +125 -5
- package/dist/queries/labels-types.d.ts +7 -6
- package/dist/queries/project-milestones-types.d.ts +5 -4
- package/dist/queries/project-milestones.d.ts +1 -1
- package/dist/queries/projects-types.d.ts +8 -7
- package/dist/queries/releases-types.d.ts +5 -4
- package/dist/queries/search-types.d.ts +28 -12
- package/dist/queries/templates-types.d.ts +3 -2
- package/dist/types/linear.d.ts +13 -1
- package/dist/utils/auto-link-references.d.ts +3 -3
- package/dist/utils/auto-link-references.js +1 -10
- 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-issues-service.d.ts +101 -45
- package/dist/utils/graphql-issues-service.js +252 -39
- 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 +81 -3
- package/dist/utils/output.js +61 -6
- 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.js +20 -4
- package/package.json +2 -2
package/dist/commands/batch.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
import { resolveAssignee, resolveLabels, resolveTeam, } from "../config/resolver.js";
|
|
2
|
-
import {
|
|
3
|
-
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
4
|
-
import { createLinearService } from "../utils/linear-service.js";
|
|
1
|
+
import { resolveAssignee, resolveLabels, resolveMember, resolveTeam, } from "../config/resolver.js";
|
|
2
|
+
import { createIssuesService } from "../utils/issues-service-bootstrap.js";
|
|
5
3
|
import { logger } from "../utils/logger.js";
|
|
6
4
|
import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
|
|
7
5
|
import { getRootOpts } from "../utils/root-opts.js";
|
|
8
6
|
import { splitList } from "../utils/validators.js";
|
|
9
7
|
/**
|
|
10
8
|
* Parse the --filter string into structured search arguments.
|
|
11
|
-
* Format: "status:Backlog team:DEV label:Bug assignee:Alice project:Sprint12"
|
|
9
|
+
* Format: "status:Backlog team:DEV label:Bug assignee:Alice delegate:Claude project:Sprint12"
|
|
12
10
|
*/
|
|
13
11
|
function parseFilterString(filter) {
|
|
14
12
|
const result = {};
|
|
@@ -30,21 +28,28 @@ function parseFilterString(filter) {
|
|
|
30
28
|
* Returns LinearIssue[] so we can show a preview.
|
|
31
29
|
*/
|
|
32
30
|
async function resolveTargetIssues(options, rootOpts) {
|
|
33
|
-
const
|
|
34
|
-
const linearService = await createLinearService(rootOpts);
|
|
35
|
-
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
31
|
+
const { issuesService } = await createIssuesService(rootOpts);
|
|
36
32
|
if (options.issues) {
|
|
37
33
|
const ids = splitList(options.issues);
|
|
38
34
|
return Promise.all(ids.map((id) => issuesService.getIssueById(id)));
|
|
39
35
|
}
|
|
40
36
|
if (options.filter) {
|
|
41
37
|
const filters = parseFilterString(options.filter);
|
|
38
|
+
// Typed as SearchIssueArgs (not Record<string, unknown>) so a future
|
|
39
|
+
// rename of any field — like the DEV-4068 T4 `projectId` → `project`
|
|
40
|
+
// migration — surfaces here at compile time instead of silently
|
|
41
|
+
// dropping the filter.
|
|
42
42
|
const searchArgs = {
|
|
43
43
|
teamId: filters.team ? resolveTeam(filters.team) : undefined,
|
|
44
44
|
assigneeId: filters.assignee
|
|
45
45
|
? await resolveAssignee(filters.assignee, rootOpts)
|
|
46
46
|
: undefined,
|
|
47
|
-
|
|
47
|
+
delegateId: filters.delegate
|
|
48
|
+
? resolveMember(filters.delegate)
|
|
49
|
+
: undefined,
|
|
50
|
+
project: filters.project
|
|
51
|
+
? { kind: "id", id: filters.project }
|
|
52
|
+
: undefined,
|
|
48
53
|
labelNames: filters.label ? splitList(filters.label) : undefined,
|
|
49
54
|
status: filters.status ? splitList(filters.status) : undefined,
|
|
50
55
|
limit: 50,
|
|
@@ -117,9 +122,7 @@ async function handleBatchAssign(options, command) {
|
|
|
117
122
|
});
|
|
118
123
|
return;
|
|
119
124
|
}
|
|
120
|
-
const
|
|
121
|
-
const linearService = await createLinearService(rootOpts);
|
|
122
|
-
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
125
|
+
const { issuesService } = await createIssuesService(rootOpts);
|
|
123
126
|
const { results } = await executeBatch(issues, (issue) => issuesService.updateIssue({ id: issue.identifier, assigneeId }, "adding"));
|
|
124
127
|
outputSuccess({
|
|
125
128
|
action: "assign",
|
|
@@ -164,9 +167,7 @@ async function handleBatchLabel(options, command) {
|
|
|
164
167
|
});
|
|
165
168
|
return;
|
|
166
169
|
}
|
|
167
|
-
const
|
|
168
|
-
const linearService = await createLinearService(rootOpts);
|
|
169
|
-
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
170
|
+
const { issuesService } = await createIssuesService(rootOpts);
|
|
170
171
|
const removeLower = removeLabels.map((l) => l.toLowerCase());
|
|
171
172
|
const { results } = await executeBatch(issues, (issue) => {
|
|
172
173
|
// Filter out labels to remove (by name, case-insensitive)
|
|
@@ -218,9 +219,7 @@ async function handleBatchMove(options, command) {
|
|
|
218
219
|
});
|
|
219
220
|
return;
|
|
220
221
|
}
|
|
221
|
-
const
|
|
222
|
-
const linearService = await createLinearService(rootOpts);
|
|
223
|
-
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
222
|
+
const { issuesService } = await createIssuesService(rootOpts);
|
|
224
223
|
const { results } = await executeBatch(issues, (issue) => issuesService.updateIssue({ id: issue.identifier, projectId: options.project }, "adding"));
|
|
225
224
|
outputSuccess({
|
|
226
225
|
action: "move",
|
|
@@ -254,9 +253,7 @@ async function handleBatchStatus(options, command) {
|
|
|
254
253
|
});
|
|
255
254
|
return;
|
|
256
255
|
}
|
|
257
|
-
const
|
|
258
|
-
const linearService = await createLinearService(rootOpts);
|
|
259
|
-
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
256
|
+
const { issuesService } = await createIssuesService(rootOpts);
|
|
260
257
|
const { results } = await executeBatch(issues, (issue) => issuesService.updateIssue({ id: issue.identifier, statusId: options.status }, "adding"));
|
|
261
258
|
outputSuccess({
|
|
262
259
|
action: "status",
|
|
@@ -18,13 +18,13 @@ import { getWorkspaceUrlKey } from "../utils/workspace-url.js";
|
|
|
18
18
|
const BODY_DATA_ERROR_RE = /prosemirror|bodydata|invalid.*body/i;
|
|
19
19
|
const ISSUE_IDENTIFIER_REGEX = /^[A-Z][A-Z0-9]*-\d+$/;
|
|
20
20
|
function readBody(options) {
|
|
21
|
-
if (options.
|
|
22
|
-
return readFileSync(options.
|
|
21
|
+
if (options.bodyFile) {
|
|
22
|
+
return readFileSync(options.bodyFile, "utf-8");
|
|
23
23
|
}
|
|
24
24
|
if (options.body) {
|
|
25
25
|
return options.body;
|
|
26
26
|
}
|
|
27
|
-
throw new Error("Either --body or --file is required");
|
|
27
|
+
throw new Error("Either --body or --body-file is required");
|
|
28
28
|
}
|
|
29
29
|
async function fetchSelfUserId(graphQLService) {
|
|
30
30
|
try {
|
|
@@ -250,7 +250,7 @@ export function setupCommentsCommands(program) {
|
|
|
250
250
|
.description("Create new comment on issue.")
|
|
251
251
|
.addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.\nBare references to known team members (e.g. 'Dima') are auto-converted to @mentions — pass --no-auto-mention to disable.\nIssue identifiers (e.g. DEV-123) are wrapped as markdown links and added as 'related' relations on the parent issue — pass --no-auto-link to disable.")
|
|
252
252
|
.option("--body <body>", "comment body (inline)")
|
|
253
|
-
.option("--file <path>", "read comment body from file")
|
|
253
|
+
.option("--body-file <path>", "read comment body from file")
|
|
254
254
|
.option("--no-auto-mention", "do not auto-convert bare team-member names to @mentions")
|
|
255
255
|
.option("--no-auto-link", "skip wrapping issue refs as markdown links and creating sidebar relations")
|
|
256
256
|
.option("--footer <text>", "text appended to the comment body (overrides config.messageFooter)")
|
|
@@ -260,7 +260,7 @@ export function setupCommentsCommands(program) {
|
|
|
260
260
|
.command("update <commentId>")
|
|
261
261
|
.description("Update an existing comment.")
|
|
262
262
|
.option("--body <body>", "new comment body (inline)")
|
|
263
|
-
.option("--file <path>", "read new comment body from file")
|
|
263
|
+
.option("--body-file <path>", "read new comment body from file")
|
|
264
264
|
.option("--no-auto-mention", "do not auto-convert bare team-member names to @mentions")
|
|
265
265
|
.option("--no-auto-link", "skip wrapping issue refs as markdown links and creating sidebar relations")
|
|
266
266
|
.action(handleAsyncCommand(handleUpdateComment));
|
package/dist/commands/config.js
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { getActiveTeamConfigInfo, getActiveTeamConfigPath, loadConfig, loadLocalConfig, } from "../config/config.js";
|
|
5
|
+
import { resolveActiveProfile } from "../config/paths.js";
|
|
6
|
+
import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
|
|
7
|
+
import { updateConfig } from "./init/shared.js";
|
|
8
|
+
/**
|
|
9
|
+
* Expand a leading `~` to the user's home directory. Shells expand `~` before
|
|
10
|
+
* the command sees it, but a literal `~/...` pasted from documentation reaches
|
|
11
|
+
* Node verbatim. We handle it so `el-linear config team set-path ~/git/...`
|
|
12
|
+
* works regardless of whether the shell did the expansion first.
|
|
13
|
+
*/
|
|
14
|
+
function expandHome(p) {
|
|
15
|
+
if (p === "~")
|
|
16
|
+
return os.homedir();
|
|
17
|
+
if (p.startsWith("~/"))
|
|
18
|
+
return path.join(os.homedir(), p.slice(2));
|
|
19
|
+
return p;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Render the team-config source token returned by `getActiveTeamConfigInfo()`
|
|
23
|
+
* as a human-readable string for `config team show`. Kept next to the command
|
|
24
|
+
* (not in `config/config.ts`) so the wire format is owned by the renderer,
|
|
25
|
+
* not the resolver. DEV-4258.
|
|
26
|
+
*/
|
|
27
|
+
function sourceLabel(source) {
|
|
28
|
+
switch (source) {
|
|
29
|
+
case "env":
|
|
30
|
+
return "EL_LINEAR_TEAM_CONFIG env var";
|
|
31
|
+
case "personal":
|
|
32
|
+
return "teamConfigPath in personal config";
|
|
33
|
+
case "marker":
|
|
34
|
+
return "auto-discovered via ~/.config/el-tools-root";
|
|
35
|
+
case null:
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const LOCAL_SETTABLE_KEYS = [
|
|
40
|
+
"assigneeEmail",
|
|
41
|
+
"defaultAssignee",
|
|
42
|
+
"defaultPriority",
|
|
43
|
+
"cacheTTLSeconds",
|
|
44
|
+
];
|
|
3
45
|
export function setupConfigCommands(program) {
|
|
4
46
|
const config = program
|
|
5
47
|
.command("config")
|
|
@@ -7,9 +49,140 @@ export function setupConfigCommands(program) {
|
|
|
7
49
|
config.action(() => config.help());
|
|
8
50
|
config
|
|
9
51
|
.command("show")
|
|
10
|
-
.description("Show resolved configuration")
|
|
11
|
-
.addHelpText("after", "\nDumps the merged config from
|
|
52
|
+
.description("Show resolved configuration (team file → personal config → local overrides)")
|
|
53
|
+
.addHelpText("after", "\nDumps the merged config from the active team file + config.json + local.json.\n\nExamples:\n el-linear config show")
|
|
54
|
+
.action(() => {
|
|
55
|
+
const data = loadConfig();
|
|
56
|
+
const teamConfig = getActiveTeamConfigPath();
|
|
57
|
+
const local = loadLocalConfig();
|
|
58
|
+
const out = { data };
|
|
59
|
+
if (teamConfig)
|
|
60
|
+
out.teamConfig = teamConfig;
|
|
61
|
+
if (Object.keys(local).length > 0)
|
|
62
|
+
out.local = local;
|
|
63
|
+
outputSuccess(out);
|
|
64
|
+
});
|
|
65
|
+
const local = config
|
|
66
|
+
.command("local")
|
|
67
|
+
.description("Manage user-local config overrides (local.json)");
|
|
68
|
+
local.action(() => local.help());
|
|
69
|
+
local
|
|
70
|
+
.command("show")
|
|
71
|
+
.description("Show the raw contents of local.json")
|
|
72
|
+
.addHelpText("after", `\nShows ~/.config/el-linear/local.json (or the per-profile equivalent).\n\nExamples:\n el-linear config local show`)
|
|
73
|
+
.action(() => {
|
|
74
|
+
outputSuccess({ data: loadLocalConfig() });
|
|
75
|
+
});
|
|
76
|
+
const team = config
|
|
77
|
+
.command("team")
|
|
78
|
+
.description("Manage the shared team config layer (teamConfigPath in personal config)");
|
|
79
|
+
team.action(() => team.help());
|
|
80
|
+
team
|
|
81
|
+
.command("show")
|
|
82
|
+
.description("Show the active team config layer: path, source (env var, personal config, or auto-discovered marker), file status, top-level keys it contributes")
|
|
83
|
+
.addHelpText("after", "\nResolves the team config in the same order loadConfig() does:\n EL_LINEAR_TEAM_CONFIG (env) > teamConfigPath (personal config) > ~/.config/el-tools-root marker\n\nExamples:\n el-linear config team show")
|
|
12
84
|
.action(() => {
|
|
13
|
-
|
|
85
|
+
const info = getActiveTeamConfigInfo();
|
|
86
|
+
const teamPath = info.path;
|
|
87
|
+
const source = sourceLabel(info.source);
|
|
88
|
+
const out = {
|
|
89
|
+
teamConfigPath: teamPath ?? null,
|
|
90
|
+
source,
|
|
91
|
+
};
|
|
92
|
+
if (teamPath) {
|
|
93
|
+
try {
|
|
94
|
+
const raw = fs.readFileSync(teamPath, "utf8");
|
|
95
|
+
const parsed = JSON.parse(raw);
|
|
96
|
+
out.exists = true;
|
|
97
|
+
out.valid = true;
|
|
98
|
+
out.providedKeys = Object.keys(parsed).sort();
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
out.exists = fs.existsSync(teamPath);
|
|
102
|
+
out.valid = false;
|
|
103
|
+
out.error = err.message;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
outputSuccess({ data: out });
|
|
14
107
|
});
|
|
108
|
+
team
|
|
109
|
+
.command("set-path <path>")
|
|
110
|
+
.description("Write teamConfigPath into personal config.json. Path is resolved to an absolute path. The target file must exist and be valid JSON — a pointer to a missing or broken file is footgun-shaped and refused.")
|
|
111
|
+
.addHelpText("after", "\nUse this when adopting a shared team config (e.g. one checked into a tools repo):\n el-linear config team set-path ~/git/enrichlayer/tools/config/el-linear.shared.json\n\nRelative paths resolve against the current working directory; absolute paths are stored as-is. EL_LINEAR_TEAM_CONFIG in the environment still overrides the personal field when both are set — a warning surfaces in that case.")
|
|
112
|
+
.action(handleAsyncCommand(async (rawPath) => {
|
|
113
|
+
const resolved = path.resolve(expandHome(rawPath));
|
|
114
|
+
if (!fs.existsSync(resolved)) {
|
|
115
|
+
throw new Error(`Team config file not found at ${resolved}. Create the file (or fix the path) before pointing personal config at it.`);
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
JSON.parse(fs.readFileSync(resolved, "utf8"));
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
throw new Error(`Team config at ${resolved} is not valid JSON: ${err.message}`);
|
|
122
|
+
}
|
|
123
|
+
await updateConfig((current) => ({
|
|
124
|
+
...current,
|
|
125
|
+
teamConfigPath: resolved,
|
|
126
|
+
}));
|
|
127
|
+
const envOverride = process.env.EL_LINEAR_TEAM_CONFIG?.trim() || undefined;
|
|
128
|
+
if (envOverride !== undefined && envOverride !== resolved) {
|
|
129
|
+
outputWarning(`EL_LINEAR_TEAM_CONFIG=${envOverride} is set in this shell and overrides the personal config field. Unset it (or align it) for the persistent setting to take effect.`);
|
|
130
|
+
}
|
|
131
|
+
outputSuccess({
|
|
132
|
+
data: {
|
|
133
|
+
teamConfigPath: resolved,
|
|
134
|
+
written: resolveActiveProfile().configPath,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}));
|
|
138
|
+
team
|
|
139
|
+
.command("clear")
|
|
140
|
+
.description("Remove teamConfigPath from personal config.json. EL_LINEAR_TEAM_CONFIG (env) is unaffected and continues to override if set.")
|
|
141
|
+
.action(handleAsyncCommand(async () => {
|
|
142
|
+
let removed;
|
|
143
|
+
await updateConfig((current) => {
|
|
144
|
+
removed = current.teamConfigPath;
|
|
145
|
+
const { teamConfigPath: _drop, ...rest } = current;
|
|
146
|
+
return rest;
|
|
147
|
+
});
|
|
148
|
+
const envOverride = process.env.EL_LINEAR_TEAM_CONFIG?.trim() || undefined;
|
|
149
|
+
if (envOverride !== undefined) {
|
|
150
|
+
outputWarning(`EL_LINEAR_TEAM_CONFIG=${envOverride} is still set in this shell; it overrides the personal field and remains active until unset.`);
|
|
151
|
+
}
|
|
152
|
+
outputSuccess({
|
|
153
|
+
data: {
|
|
154
|
+
cleared: true,
|
|
155
|
+
previousTeamConfigPath: removed ?? null,
|
|
156
|
+
written: resolveActiveProfile().configPath,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
}));
|
|
160
|
+
local
|
|
161
|
+
.command("set <key> <value>")
|
|
162
|
+
.description(`Set a key in local.json. Keys: ${LOCAL_SETTABLE_KEYS.join(", ")}`)
|
|
163
|
+
.addHelpText("after", `\nAllowed keys:\n assigneeEmail — your Linear account email (used as default --assignee)\n defaultAssignee — alias / display name / email / UUID\n defaultPriority — none|urgent|high|medium|normal|low\n cacheTTLSeconds — integer seconds (0 = disable cache)\n\nExamples:\n el-linear config local set assigneeEmail you@example.com\n el-linear config local set defaultPriority medium\n el-linear config local set cacheTTLSeconds 0`)
|
|
164
|
+
.action(handleAsyncCommand(async (key, value) => {
|
|
165
|
+
if (!LOCAL_SETTABLE_KEYS.includes(key)) {
|
|
166
|
+
throw new Error(`Unknown local config key "${key}". Allowed: ${LOCAL_SETTABLE_KEYS.join(", ")}`);
|
|
167
|
+
}
|
|
168
|
+
const active = resolveActiveProfile();
|
|
169
|
+
const localPath = active.localConfigPath;
|
|
170
|
+
let existing = {};
|
|
171
|
+
if (fs.existsSync(localPath)) {
|
|
172
|
+
try {
|
|
173
|
+
existing = JSON.parse(fs.readFileSync(localPath, "utf8"));
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// overwrite a corrupt file
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const typedValue = key === "cacheTTLSeconds" ? Number(value) : value;
|
|
180
|
+
if (key === "cacheTTLSeconds" && Number.isNaN(typedValue)) {
|
|
181
|
+
throw new Error(`cacheTTLSeconds must be an integer, got "${value}"`);
|
|
182
|
+
}
|
|
183
|
+
const updated = { ...existing, [key]: typedValue };
|
|
184
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
185
|
+
fs.writeFileSync(localPath, `${JSON.stringify(updated, null, 2)}\n`, "utf8");
|
|
186
|
+
outputSuccess({ data: updated });
|
|
187
|
+
}));
|
|
15
188
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* with no input is a no-op.
|
|
7
7
|
*/
|
|
8
8
|
import { type WizardConfig } from "./shared.js";
|
|
9
|
-
|
|
9
|
+
interface DefaultsStepResult {
|
|
10
10
|
defaultLabels: string[] | undefined;
|
|
11
11
|
/**
|
|
12
12
|
* Default assignee identifier (alias / display name / email / UUID — same
|
|
@@ -40,3 +40,4 @@ export interface DefaultsStepResult {
|
|
|
40
40
|
cacheTTLSeconds: number | undefined;
|
|
41
41
|
}
|
|
42
42
|
export declare function runDefaultsStep(existing: WizardConfig): Promise<DefaultsStepResult>;
|
|
43
|
+
export {};
|
|
@@ -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.
|