@enrichlayer/el-linear 1.7.0 → 1.10.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 +82 -1
- package/claude-skills/linear-operations/SKILL.md +55 -0
- package/dist/auth/oauth-callback.js +14 -7
- package/dist/auth/oauth-fs.d.ts +22 -0
- package/dist/auth/oauth-fs.js +77 -6
- package/dist/auth/oauth-headless.d.ts +13 -8
- package/dist/auth/oauth-headless.js +18 -11
- package/dist/auth/oauth-storage.d.ts +11 -3
- package/dist/auth/oauth-storage.js +15 -8
- package/dist/auth/token-resolver.d.ts +9 -0
- package/dist/auth/token-resolver.js +57 -37
- package/dist/commands/attachments.js +2 -1
- package/dist/commands/comments.js +17 -28
- 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/index.js +2 -0
- package/dist/commands/init/oauth.d.ts +6 -0
- package/dist/commands/init/oauth.js +8 -2
- package/dist/commands/init/token.d.ts +0 -8
- package/dist/commands/init/token.js +13 -1
- package/dist/commands/issue-id.js +1 -3
- package/dist/commands/issues/branch.d.ts +17 -0
- package/dist/commands/issues/branch.js +40 -0
- package/dist/commands/issues/description.d.ts +89 -0
- package/dist/commands/issues/description.js +183 -0
- 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 +55 -0
- package/dist/commands/issues/relations.js +132 -0
- package/dist/commands/issues.js +97 -497
- package/dist/commands/labels.js +20 -28
- package/dist/commands/profile/migrate-legacy.js +10 -33
- package/dist/commands/profile.js +1 -10
- package/dist/commands/project-milestones.js +13 -20
- package/dist/commands/projects.d.ts +5 -1
- package/dist/commands/projects.js +202 -102
- package/dist/commands/read-shortcut.d.ts +6 -0
- package/dist/commands/read-shortcut.js +6 -1
- package/dist/commands/refs.js +10 -1
- package/dist/commands/releases.js +26 -30
- package/dist/commands/search.js +21 -30
- package/dist/commands/teams.js +2 -1
- package/dist/commands/templates.js +128 -7
- package/dist/commands/users.js +3 -1
- package/dist/config/config.js +31 -9
- package/dist/config/issue-validation.js +1 -1
- package/dist/config/paths.d.ts +11 -0
- package/dist/config/paths.js +44 -6
- package/dist/config/resolver.js +2 -3
- package/dist/config/term-enforcer.js +1 -1
- package/dist/main.js +37 -2
- package/dist/queries/attachments-types.d.ts +30 -0
- package/dist/queries/attachments-types.js +5 -0
- package/dist/queries/comments-types.d.ts +49 -0
- package/dist/queries/comments-types.js +5 -0
- package/dist/queries/documents-types.d.ts +61 -0
- package/dist/queries/documents-types.js +9 -0
- package/dist/queries/introspect-types.d.ts +57 -0
- package/dist/queries/introspect-types.js +10 -0
- package/dist/queries/issues-types.d.ts +416 -0
- package/dist/queries/issues-types.js +23 -0
- package/dist/queries/issues.d.ts +2 -0
- package/dist/queries/issues.js +22 -0
- package/dist/queries/labels-types.d.ts +64 -0
- package/dist/queries/labels-types.js +5 -0
- package/dist/queries/project-milestones-types.d.ts +91 -0
- package/dist/queries/project-milestones-types.js +10 -0
- package/dist/queries/projects-types.d.ts +75 -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 +84 -0
- package/dist/queries/releases-types.js +5 -0
- package/dist/queries/search-types.d.ts +86 -0
- package/dist/queries/search-types.js +6 -0
- package/dist/queries/templates-types.d.ts +61 -0
- package/dist/queries/templates-types.js +9 -0
- package/dist/queries/templates.d.ts +3 -0
- package/dist/queries/templates.js +43 -0
- package/dist/types/linear.d.ts +8 -2
- package/dist/utils/auth.js +7 -9
- package/dist/utils/auto-link-references.js +44 -25
- package/dist/utils/disk-cache.js +2 -1
- package/dist/utils/formatters/summary.d.ts +62 -0
- package/dist/utils/formatters/summary.js +755 -0
- 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 +118 -5
- package/dist/utils/graphql-issues-service.js +202 -209
- package/dist/utils/issue-reference-extractor.d.ts +9 -1
- package/dist/utils/issue-reference-extractor.js +16 -9
- package/dist/utils/issue-reference-wrapper.js +1 -54
- package/dist/utils/linear-service.d.ts +7 -3
- package/dist/utils/linear-service.js +27 -5
- package/dist/utils/markdown-prosemirror.js +17 -1
- package/dist/utils/mention-resolver.js +17 -5
- package/dist/utils/output.d.ts +7 -1
- package/dist/utils/output.js +38 -1
- package/dist/utils/protected-ranges.d.ts +33 -0
- package/dist/utils/protected-ranges.js +73 -0
- package/dist/utils/table-formatter.d.ts +36 -0
- package/dist/utils/table-formatter.js +46 -24
- package/dist/utils/validators.d.ts +9 -1
- package/dist/utils/validators.js +10 -0
- package/dist/utils/workspace-url.d.ts +5 -1
- package/dist/utils/workspace-url.js +35 -5
- package/package.json +1 -1
package/dist/commands/search.js
CHANGED
|
@@ -3,6 +3,7 @@ import { SEMANTIC_SEARCH_QUERY } from "../queries/search.js";
|
|
|
3
3
|
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
4
4
|
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
5
5
|
import { getRootOpts } from "../utils/root-opts.js";
|
|
6
|
+
import { parsePositiveInt } from "../utils/validators.js";
|
|
6
7
|
const TEMPLATES_QUERY = `
|
|
7
8
|
query {
|
|
8
9
|
templates {
|
|
@@ -24,59 +25,50 @@ const VALID_TYPES = new Set([
|
|
|
24
25
|
]);
|
|
25
26
|
const WHITESPACE_RE = /\s+/;
|
|
26
27
|
function transformSearchResult(r) {
|
|
27
|
-
|
|
28
|
-
switch (rType) {
|
|
28
|
+
switch (r.type) {
|
|
29
29
|
case "issue": {
|
|
30
30
|
const issue = r.issue;
|
|
31
|
-
const state = issue?.state;
|
|
32
|
-
const team = issue?.team;
|
|
33
|
-
const assignee = issue?.assignee;
|
|
34
|
-
const project = issue?.project;
|
|
35
31
|
return {
|
|
36
32
|
type: "issue",
|
|
37
33
|
identifier: issue?.identifier,
|
|
38
34
|
title: issue?.title,
|
|
39
|
-
state: state?.name,
|
|
40
|
-
team: team?.key,
|
|
41
|
-
assignee: assignee
|
|
42
|
-
? resolveUserDisplayName(assignee.id, assignee.name)
|
|
35
|
+
state: issue?.state?.name,
|
|
36
|
+
team: issue?.team?.key,
|
|
37
|
+
assignee: issue?.assignee
|
|
38
|
+
? resolveUserDisplayName(issue.assignee.id, issue.assignee.name)
|
|
43
39
|
: undefined,
|
|
44
|
-
priority: issue?.priority,
|
|
45
|
-
project: project?.name,
|
|
40
|
+
priority: issue?.priority ?? undefined,
|
|
41
|
+
project: issue?.project?.name,
|
|
46
42
|
id: issue?.id,
|
|
47
43
|
};
|
|
48
44
|
}
|
|
49
45
|
case "project": {
|
|
50
|
-
const project = r.project;
|
|
51
46
|
return {
|
|
52
47
|
type: "project",
|
|
53
|
-
name: project?.name,
|
|
54
|
-
state: project?.state,
|
|
55
|
-
id: project?.id,
|
|
48
|
+
name: r.project?.name,
|
|
49
|
+
state: r.project?.state,
|
|
50
|
+
id: r.project?.id,
|
|
56
51
|
};
|
|
57
52
|
}
|
|
58
53
|
case "initiative": {
|
|
59
|
-
const initiative = r.initiative;
|
|
60
54
|
return {
|
|
61
55
|
type: "initiative",
|
|
62
|
-
name: initiative?.name,
|
|
63
|
-
status: initiative?.status,
|
|
64
|
-
id: initiative?.id,
|
|
56
|
+
name: r.initiative?.name,
|
|
57
|
+
status: r.initiative?.status ?? undefined,
|
|
58
|
+
id: r.initiative?.id,
|
|
65
59
|
};
|
|
66
60
|
}
|
|
67
61
|
case "document": {
|
|
68
|
-
const doc = r.document;
|
|
69
|
-
const docProject = doc?.project;
|
|
70
62
|
return {
|
|
71
63
|
type: "document",
|
|
72
|
-
title:
|
|
73
|
-
project:
|
|
74
|
-
slugId:
|
|
75
|
-
id:
|
|
64
|
+
title: r.document?.title,
|
|
65
|
+
project: r.document?.project?.name,
|
|
66
|
+
slugId: r.document?.slugId ?? undefined,
|
|
67
|
+
id: r.document?.id,
|
|
76
68
|
};
|
|
77
69
|
}
|
|
78
70
|
default:
|
|
79
|
-
return { type:
|
|
71
|
+
return { type: r.type, id: null };
|
|
80
72
|
}
|
|
81
73
|
}
|
|
82
74
|
function matchesQuery(name, query) {
|
|
@@ -122,8 +114,7 @@ function buildSemanticQuery(graphQLService, query, limit, teamOption) {
|
|
|
122
114
|
});
|
|
123
115
|
}
|
|
124
116
|
function extractSemanticResults(semanticResult, requestedTypes, onlyTemplates) {
|
|
125
|
-
|
|
126
|
-
let results = semanticSearch?.results ?? [];
|
|
117
|
+
let results = semanticResult.semanticSearch?.results ?? [];
|
|
127
118
|
if (requestedTypes && !onlyTemplates) {
|
|
128
119
|
const semanticTypes = requestedTypes.filter((t) => t !== "template");
|
|
129
120
|
results = results.filter((r) => semanticTypes.includes(r.type));
|
|
@@ -140,7 +131,7 @@ export function setupSearchCommands(program) {
|
|
|
140
131
|
.action(handleAsyncCommand(async (query, options, command) => {
|
|
141
132
|
const rootOpts = getRootOpts(command);
|
|
142
133
|
const graphQLService = await createGraphQLService(rootOpts);
|
|
143
|
-
const limit =
|
|
134
|
+
const limit = parsePositiveInt(options.limit, "--limit");
|
|
144
135
|
const requestedTypes = options.type
|
|
145
136
|
? options.type.split(",").map((t) => t.trim().toLowerCase())
|
|
146
137
|
: null;
|
package/dist/commands/teams.js
CHANGED
|
@@ -3,6 +3,7 @@ import { cached, resolveCacheTTL } from "../utils/disk-cache.js";
|
|
|
3
3
|
import { createLinearService } from "../utils/linear-service.js";
|
|
4
4
|
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
5
5
|
import { getRootOpts } from "../utils/root-opts.js";
|
|
6
|
+
import { parsePositiveInt } from "../utils/validators.js";
|
|
6
7
|
export function setupTeamsCommands(program) {
|
|
7
8
|
const teams = program
|
|
8
9
|
.command("teams")
|
|
@@ -15,7 +16,7 @@ export function setupTeamsCommands(program) {
|
|
|
15
16
|
.option("-l, --limit <number>", "limit results", "100")
|
|
16
17
|
.action(handleAsyncCommand(async (options, command) => {
|
|
17
18
|
const rootOpts = getRootOpts(command);
|
|
18
|
-
const limit =
|
|
19
|
+
const limit = parsePositiveInt(options.limit, "--limit");
|
|
19
20
|
const ttl = resolveCacheTTL({
|
|
20
21
|
configTTL: loadConfig().cacheTTLSeconds,
|
|
21
22
|
// commander's `--no-cache` produces `cache: false` on the root opts.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { TEMPLATE_BY_ID_QUERY, TEMPLATE_CREATE_MUTATION, TEMPLATE_DELETE_MUTATION, TEMPLATE_UPDATE_MUTATION, TEMPLATES_LIST_QUERY, } from "../queries/templates.js";
|
|
2
3
|
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
3
4
|
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
4
5
|
import { getRootOpts } from "../utils/root-opts.js";
|
|
@@ -47,13 +48,133 @@ export function setupTemplatesCommands(program) {
|
|
|
47
48
|
.action(handleAsyncCommand(async (templateId, _options, command) => {
|
|
48
49
|
const rootOpts = getRootOpts(command);
|
|
49
50
|
const graphQLService = await createGraphQLService(rootOpts);
|
|
50
|
-
const result = await graphQLService.rawRequest(TEMPLATE_BY_ID_QUERY, {
|
|
51
|
-
|
|
52
|
-
});
|
|
53
|
-
const template = result.template;
|
|
54
|
-
if (!template) {
|
|
51
|
+
const result = await graphQLService.rawRequest(TEMPLATE_BY_ID_QUERY, { id: templateId });
|
|
52
|
+
if (!result.template) {
|
|
55
53
|
throw new Error(`Template "${templateId}" not found`);
|
|
56
54
|
}
|
|
57
|
-
outputSuccess(template);
|
|
55
|
+
outputSuccess(result.template);
|
|
56
|
+
}));
|
|
57
|
+
templates
|
|
58
|
+
.command("create")
|
|
59
|
+
.description("Create a new issue / document / project template")
|
|
60
|
+
.requiredOption("--type <type>", "template kind (issue, document, project, recurringIssue)")
|
|
61
|
+
.requiredOption("--name <name>", "template display name")
|
|
62
|
+
.requiredOption("--team-id <id>", "team id (UUID) the template belongs to. See `el-linear teams list`")
|
|
63
|
+
.option("--description <text>", "description shown in the template picker")
|
|
64
|
+
.option("--data <json>", "templateData as a JSON string (typically { title, description, labelIds, priority })")
|
|
65
|
+
.option("--data-file <path>", "templateData read from a JSON file (relative path). Mutually exclusive with --data")
|
|
66
|
+
.option("--allow-absolute", "allow --data-file to read absolute or `..`-traversing paths (file contents are sent to Linear's API)")
|
|
67
|
+
.option("--icon <name>", "Lucide icon name shown in the picker")
|
|
68
|
+
.option("--color <hex>", "color (hex) for the template badge")
|
|
69
|
+
.action(handleAsyncCommand(async (options, command) => {
|
|
70
|
+
const rootOpts = getRootOpts(command);
|
|
71
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
72
|
+
const templateData = parseTemplateData(options);
|
|
73
|
+
const input = {
|
|
74
|
+
type: options.type,
|
|
75
|
+
name: options.name,
|
|
76
|
+
teamId: options.teamId,
|
|
77
|
+
};
|
|
78
|
+
if (options.description)
|
|
79
|
+
input.description = options.description;
|
|
80
|
+
if (templateData !== undefined)
|
|
81
|
+
input.templateData = templateData;
|
|
82
|
+
if (options.icon)
|
|
83
|
+
input.icon = options.icon;
|
|
84
|
+
if (options.color)
|
|
85
|
+
input.color = options.color;
|
|
86
|
+
const result = await graphQLService.rawRequest(TEMPLATE_CREATE_MUTATION, { input });
|
|
87
|
+
if (!result.templateCreate.success || !result.templateCreate.template) {
|
|
88
|
+
throw new Error("templateCreate returned success=false");
|
|
89
|
+
}
|
|
90
|
+
outputSuccess(result.templateCreate.template);
|
|
91
|
+
}));
|
|
92
|
+
templates
|
|
93
|
+
.command("update <templateId>")
|
|
94
|
+
.description("Update a template's name, description, templateData, icon, or color")
|
|
95
|
+
.option("--name <name>", "new display name")
|
|
96
|
+
.option("--description <text>", "new description")
|
|
97
|
+
.option("--data <json>", "new templateData as a JSON string")
|
|
98
|
+
.option("--data-file <path>", "new templateData read from a JSON file (relative path)")
|
|
99
|
+
.option("--allow-absolute", "allow --data-file to read absolute or `..`-traversing paths")
|
|
100
|
+
.option("--icon <name>", "Lucide icon name")
|
|
101
|
+
.option("--color <hex>", "color (hex)")
|
|
102
|
+
.option("--team-id <id>", "move the template to a different team")
|
|
103
|
+
.action(handleAsyncCommand(async (templateId, options, command) => {
|
|
104
|
+
const rootOpts = getRootOpts(command);
|
|
105
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
106
|
+
const input = {};
|
|
107
|
+
if (options.name)
|
|
108
|
+
input.name = options.name;
|
|
109
|
+
if (options.description !== undefined) {
|
|
110
|
+
input.description = options.description;
|
|
111
|
+
}
|
|
112
|
+
const templateData = parseTemplateData(options);
|
|
113
|
+
if (templateData !== undefined)
|
|
114
|
+
input.templateData = templateData;
|
|
115
|
+
if (options.icon)
|
|
116
|
+
input.icon = options.icon;
|
|
117
|
+
if (options.color)
|
|
118
|
+
input.color = options.color;
|
|
119
|
+
if (options.teamId)
|
|
120
|
+
input.teamId = options.teamId;
|
|
121
|
+
if (Object.keys(input).length === 0) {
|
|
122
|
+
throw new Error("templates update: at least one of --name, --description, --data, --data-file, --icon, --color, --team-id is required");
|
|
123
|
+
}
|
|
124
|
+
const result = await graphQLService.rawRequest(TEMPLATE_UPDATE_MUTATION, { id: templateId, input });
|
|
125
|
+
if (!result.templateUpdate.success ||
|
|
126
|
+
!result.templateUpdate.template) {
|
|
127
|
+
throw new Error("templateUpdate returned success=false");
|
|
128
|
+
}
|
|
129
|
+
outputSuccess(result.templateUpdate.template);
|
|
130
|
+
}));
|
|
131
|
+
templates
|
|
132
|
+
.command("delete <templateId>")
|
|
133
|
+
.description("Delete a template (cannot be undone)")
|
|
134
|
+
.action(handleAsyncCommand(async (templateId, _options, command) => {
|
|
135
|
+
const rootOpts = getRootOpts(command);
|
|
136
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
137
|
+
const result = await graphQLService.rawRequest(TEMPLATE_DELETE_MUTATION, { id: templateId });
|
|
138
|
+
if (!result.templateDelete.success) {
|
|
139
|
+
throw new Error("templateDelete returned success=false");
|
|
140
|
+
}
|
|
141
|
+
outputSuccess({ id: templateId, deleted: true });
|
|
58
142
|
}));
|
|
59
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Resolve `--data <json>` or `--data-file <path>` into the parsed
|
|
146
|
+
* templateData object. Returns undefined when neither flag was set
|
|
147
|
+
* so the caller can omit the field from the input object.
|
|
148
|
+
*/
|
|
149
|
+
function parseTemplateData(options) {
|
|
150
|
+
if (options.data && options.dataFile) {
|
|
151
|
+
throw new Error("Pass either --data or --data-file, not both");
|
|
152
|
+
}
|
|
153
|
+
if (options.data) {
|
|
154
|
+
try {
|
|
155
|
+
return JSON.parse(options.data);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
throw new Error(`--data is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (options.dataFile) {
|
|
162
|
+
const dataFile = String(options.dataFile);
|
|
163
|
+
// Reject absolute paths and `..` traversal unless explicitly
|
|
164
|
+
// allowed. CI invocations or scripted callers can pass an
|
|
165
|
+
// attacker-controlled path otherwise (e.g. `~/.aws/credentials.json`,
|
|
166
|
+
// `/etc/passwd`) and have its contents flow into Linear's API
|
|
167
|
+
// via the `templateData` field.
|
|
168
|
+
if (!options.allowAbsolute && /^(\/|\.\.\/|\.\.\\)/.test(dataFile)) {
|
|
169
|
+
throw new Error(`--data-file ${dataFile} resolves outside the current directory. Pass --allow-absolute to opt in (sends file contents to Linear's API).`);
|
|
170
|
+
}
|
|
171
|
+
const raw = readFileSync(dataFile, "utf8");
|
|
172
|
+
try {
|
|
173
|
+
return JSON.parse(raw);
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
throw new Error(`--data-file ${dataFile} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
package/dist/commands/users.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createLinearService } from "../utils/linear-service.js";
|
|
2
2
|
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
3
3
|
import { getRootOpts } from "../utils/root-opts.js";
|
|
4
|
+
import { parsePositiveInt } from "../utils/validators.js";
|
|
4
5
|
export function setupUsersCommands(program) {
|
|
5
6
|
const users = program.command("users").description("User operations");
|
|
6
7
|
users.action(() => users.help());
|
|
@@ -8,11 +9,12 @@ export function setupUsersCommands(program) {
|
|
|
8
9
|
.command("list")
|
|
9
10
|
.description("List all users")
|
|
10
11
|
.option("--active", "Only show active users")
|
|
12
|
+
.option("--name <substring>", "filter by case-insensitive substring on user name")
|
|
11
13
|
.option("-l, --limit <number>", "limit results", "100")
|
|
12
14
|
.action(handleAsyncCommand(async (options, command) => {
|
|
13
15
|
const rootOpts = getRootOpts(command);
|
|
14
16
|
const service = await createLinearService(rootOpts);
|
|
15
|
-
const result = await service.getUsers(options.active,
|
|
17
|
+
const result = await service.getUsers(options.active, parsePositiveInt(options.limit, "--limit"), options.name);
|
|
16
18
|
outputSuccess({ data: result, meta: { count: result.length } });
|
|
17
19
|
}));
|
|
18
20
|
}
|
package/dist/config/config.js
CHANGED
|
@@ -22,23 +22,38 @@ const DEFAULT_CONFIG = {
|
|
|
22
22
|
},
|
|
23
23
|
terms: [],
|
|
24
24
|
};
|
|
25
|
-
|
|
25
|
+
// Profile-keyed cache. Pre-fix this was a single `cachedConfig` —
|
|
26
|
+
// switching the active profile mid-process and calling `loadConfig`
|
|
27
|
+
// again would return the OLD profile's config until
|
|
28
|
+
// `_resetConfigCacheForTests` ran. Today the CLI sets the profile
|
|
29
|
+
// in `preAction` before any command body, so this is latent — but
|
|
30
|
+
// keying by profile makes it future-proof and makes test isolation
|
|
31
|
+
// less footgun-prone (profile A's setup pollutes profile B's read).
|
|
32
|
+
// ALL-935 deferred fix.
|
|
33
|
+
//
|
|
34
|
+
// Key: `null` for the legacy single-file layout (no active profile),
|
|
35
|
+
// otherwise the profile name. We never mix the two paths in the
|
|
36
|
+
// cache — the marker name selects exactly one path.
|
|
37
|
+
const cachedConfigByProfile = new Map();
|
|
26
38
|
/** Test seam — resets the cache between test cases. */
|
|
27
39
|
export function _resetConfigCacheForTests() {
|
|
28
|
-
|
|
40
|
+
cachedConfigByProfile.clear();
|
|
29
41
|
}
|
|
30
42
|
export function loadConfig() {
|
|
31
|
-
if (cachedConfig) {
|
|
32
|
-
return cachedConfig;
|
|
33
|
-
}
|
|
34
43
|
// Profile-aware: read from <CONFIG_DIR>/profiles/<name>/config.json
|
|
35
44
|
// when a profile is active, falling back to the legacy single-file
|
|
36
45
|
// path so existing setups keep working without migration.
|
|
37
46
|
const active = resolveActiveProfile();
|
|
47
|
+
const cacheKey = active.name;
|
|
48
|
+
const cached = cachedConfigByProfile.get(cacheKey);
|
|
49
|
+
if (cached) {
|
|
50
|
+
return cached;
|
|
51
|
+
}
|
|
38
52
|
const candidates = [active.configPath];
|
|
39
53
|
if (active.configPath !== CONFIG_PATH)
|
|
40
54
|
candidates.push(CONFIG_PATH);
|
|
41
55
|
const sourcePath = candidates.find((p) => fs.existsSync(p));
|
|
56
|
+
let resolved;
|
|
42
57
|
if (sourcePath) {
|
|
43
58
|
try {
|
|
44
59
|
const userConfig = JSON.parse(fs.readFileSync(sourcePath, "utf8"));
|
|
@@ -61,11 +76,11 @@ export function loadConfig() {
|
|
|
61
76
|
}
|
|
62
77
|
delete userConfig.brand;
|
|
63
78
|
}
|
|
64
|
-
|
|
79
|
+
resolved = deepMerge(DEFAULT_CONFIG, userConfig);
|
|
65
80
|
}
|
|
66
81
|
catch {
|
|
67
82
|
outputWarning(`Failed to parse ${sourcePath}, using empty defaults`);
|
|
68
|
-
|
|
83
|
+
resolved = DEFAULT_CONFIG;
|
|
69
84
|
}
|
|
70
85
|
}
|
|
71
86
|
else {
|
|
@@ -73,13 +88,20 @@ export function loadConfig() {
|
|
|
73
88
|
? ` (active profile: \`${active.name}\` — expected at ${active.configPath})`
|
|
74
89
|
: "";
|
|
75
90
|
outputWarning(`No config found at ${active.configPath}${profileNote}. Run \`el-linear init\` (or \`el-linear profile add ${active.name ?? "<name>"}\`) to create one.`);
|
|
76
|
-
|
|
91
|
+
resolved = DEFAULT_CONFIG;
|
|
77
92
|
}
|
|
78
|
-
|
|
93
|
+
cachedConfigByProfile.set(cacheKey, resolved);
|
|
94
|
+
return resolved;
|
|
79
95
|
}
|
|
80
96
|
function deepMerge(target, source) {
|
|
81
97
|
const result = { ...target };
|
|
82
98
|
for (const key of Object.keys(source)) {
|
|
99
|
+
// Reject prototype-pollution keys regardless of value shape: a
|
|
100
|
+
// hand-edited config.json with `__proto__` would otherwise mutate
|
|
101
|
+
// Object.prototype for the whole process.
|
|
102
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
83
105
|
if (source[key] &&
|
|
84
106
|
typeof source[key] === "object" &&
|
|
85
107
|
!Array.isArray(source[key]) &&
|
|
@@ -252,7 +252,7 @@ function checkTitleVerbAlignment(title, typeLabel, result) {
|
|
|
252
252
|
*/
|
|
253
253
|
export function enforceValidation(result) {
|
|
254
254
|
for (const warning of result.warnings) {
|
|
255
|
-
outputWarning(warning
|
|
255
|
+
outputWarning(warning);
|
|
256
256
|
}
|
|
257
257
|
if (result.errors.length > 0) {
|
|
258
258
|
const errorMsg = "Issue creation blocked by validation:\n\n" +
|
package/dist/config/paths.d.ts
CHANGED
|
@@ -31,9 +31,20 @@ export interface ProfileFsOps {
|
|
|
31
31
|
readFileSync: (p: string) => string;
|
|
32
32
|
existsSync: (p: string) => boolean;
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Conservative profile-name charset. Rejects path traversal (`..`, `/`),
|
|
36
|
+
* shell metacharacters, and Unicode lookalikes that would confuse `profile
|
|
37
|
+
* use` or let `--profile`/EL_LINEAR_PROFILE/active-profile escape out of
|
|
38
|
+
* <CONFIG_DIR>/profiles/.
|
|
39
|
+
*/
|
|
40
|
+
export declare function isSafeProfileName(name: string): boolean;
|
|
34
41
|
/**
|
|
35
42
|
* Set the active profile for the duration of this process. Pass `null`
|
|
36
43
|
* to clear the override and fall back to env / on-disk markers.
|
|
44
|
+
*
|
|
45
|
+
* @throws when `name` is not a safe profile name. Validation lives here
|
|
46
|
+
* so every entry point (CLI flag, env var, marker file) goes through one
|
|
47
|
+
* gate — see `resolveActiveProfile`.
|
|
37
48
|
*/
|
|
38
49
|
export declare function setActiveProfileForSession(name: string | null): void;
|
|
39
50
|
/** Read-only accessor for the per-session override (test seam). */
|
package/dist/config/paths.js
CHANGED
|
@@ -44,6 +44,15 @@ const DEFAULT_FS_OPS = {
|
|
|
44
44
|
readFileSync: (p) => fs.readFileSync(p, "utf8"),
|
|
45
45
|
existsSync: (p) => fs.existsSync(p),
|
|
46
46
|
};
|
|
47
|
+
/**
|
|
48
|
+
* Conservative profile-name charset. Rejects path traversal (`..`, `/`),
|
|
49
|
+
* shell metacharacters, and Unicode lookalikes that would confuse `profile
|
|
50
|
+
* use` or let `--profile`/EL_LINEAR_PROFILE/active-profile escape out of
|
|
51
|
+
* <CONFIG_DIR>/profiles/.
|
|
52
|
+
*/
|
|
53
|
+
export function isSafeProfileName(name) {
|
|
54
|
+
return /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(name);
|
|
55
|
+
}
|
|
47
56
|
/**
|
|
48
57
|
* Per-process override for the active profile name. Set by main.ts when
|
|
49
58
|
* `--profile <name>` is passed (highest priority).
|
|
@@ -52,9 +61,25 @@ let sessionProfileOverride = null;
|
|
|
52
61
|
/**
|
|
53
62
|
* Set the active profile for the duration of this process. Pass `null`
|
|
54
63
|
* to clear the override and fall back to env / on-disk markers.
|
|
64
|
+
*
|
|
65
|
+
* @throws when `name` is not a safe profile name. Validation lives here
|
|
66
|
+
* so every entry point (CLI flag, env var, marker file) goes through one
|
|
67
|
+
* gate — see `resolveActiveProfile`.
|
|
55
68
|
*/
|
|
56
69
|
export function setActiveProfileForSession(name) {
|
|
57
|
-
|
|
70
|
+
if (name === null) {
|
|
71
|
+
sessionProfileOverride = null;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const trimmed = name.trim();
|
|
75
|
+
if (trimmed.length === 0) {
|
|
76
|
+
sessionProfileOverride = null;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!isSafeProfileName(trimmed)) {
|
|
80
|
+
throw new Error(`Invalid profile name "${trimmed}". Allowed: [A-Za-z0-9][A-Za-z0-9_.-]{0,63}.`);
|
|
81
|
+
}
|
|
82
|
+
sessionProfileOverride = trimmed;
|
|
58
83
|
}
|
|
59
84
|
/** Read-only accessor for the per-session override (test seam). */
|
|
60
85
|
export function getSessionProfileOverride() {
|
|
@@ -66,11 +91,24 @@ export function getSessionProfileOverride() {
|
|
|
66
91
|
* keep working without migration.
|
|
67
92
|
*/
|
|
68
93
|
export function resolveActiveProfile(env = process.env, fsImpl = DEFAULT_FS_OPS) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
94
|
+
// session override is already validated at write time (see
|
|
95
|
+
// setActiveProfileForSession). Env var is explicit user input and
|
|
96
|
+
// must fail loudly. Marker file is our own on-disk state; treat
|
|
97
|
+
// invalid contents as "no marker" so a stale/corrupt file doesn't
|
|
98
|
+
// brick every command.
|
|
99
|
+
if (sessionProfileOverride) {
|
|
100
|
+
return profilePaths(sessionProfileOverride);
|
|
101
|
+
}
|
|
102
|
+
const envName = env.EL_LINEAR_PROFILE?.trim();
|
|
103
|
+
if (envName) {
|
|
104
|
+
if (!isSafeProfileName(envName)) {
|
|
105
|
+
throw new Error(`Invalid EL_LINEAR_PROFILE value "${envName}". Allowed: [A-Za-z0-9][A-Za-z0-9_.-]{0,63}.`);
|
|
106
|
+
}
|
|
107
|
+
return profilePaths(envName);
|
|
108
|
+
}
|
|
109
|
+
const marker = readActiveProfileMarker(fsImpl);
|
|
110
|
+
if (marker && isSafeProfileName(marker)) {
|
|
111
|
+
return profilePaths(marker);
|
|
74
112
|
}
|
|
75
113
|
return {
|
|
76
114
|
name: null,
|
package/dist/config/resolver.js
CHANGED
|
@@ -93,11 +93,10 @@ export async function resolveAssignee(input, rootOpts) {
|
|
|
93
93
|
if (input.toLowerCase() === "me") {
|
|
94
94
|
const graphQLService = await createGraphQLService(rootOpts);
|
|
95
95
|
const result = await graphQLService.rawRequest("{ viewer { id } }");
|
|
96
|
-
|
|
97
|
-
if (!viewer?.id) {
|
|
96
|
+
if (!result.viewer?.id) {
|
|
98
97
|
throw new Error('Could not resolve "me" — viewer query returned no user. Check your API token.');
|
|
99
98
|
}
|
|
100
|
-
return viewer.id;
|
|
99
|
+
return result.viewer.id;
|
|
101
100
|
}
|
|
102
101
|
return resolveMember(input);
|
|
103
102
|
}
|
|
@@ -62,7 +62,7 @@ export function enforceTerms(texts, options = {}) {
|
|
|
62
62
|
if (options.strict) {
|
|
63
63
|
throw new Error(`Term enforcement failed:\n${warnings.map((w) => ` - ${w}`).join("\n")}`);
|
|
64
64
|
}
|
|
65
|
-
outputWarning(warnings
|
|
65
|
+
outputWarning(warnings);
|
|
66
66
|
}
|
|
67
67
|
function escapeRegExp(string) {
|
|
68
68
|
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
package/dist/main.js
CHANGED
|
@@ -24,16 +24,17 @@ import { setupTeamsCommands } from "./commands/teams.js";
|
|
|
24
24
|
import { setupTemplatesCommands } from "./commands/templates.js";
|
|
25
25
|
import { setupUsersCommands } from "./commands/users.js";
|
|
26
26
|
import { setActiveProfileForSession } from "./config/paths.js";
|
|
27
|
-
import { setFieldsFilter, setJqFilter, setRawMode } from "./utils/output.js";
|
|
27
|
+
import { setFieldsFilter, setJqFilter, setOutputFormat, setRawMode, } from "./utils/output.js";
|
|
28
28
|
import { outputUsageInfo } from "./utils/usage.js";
|
|
29
29
|
import { splitList } from "./utils/validators.js";
|
|
30
30
|
program
|
|
31
31
|
.name("el-linear")
|
|
32
32
|
.description("A pragmatic CLI for Linear.app — deterministic resolution, structured validation, GraphQL escape hatch.")
|
|
33
|
-
.version("1.
|
|
33
|
+
.version("1.8.1")
|
|
34
34
|
.option("--api-token <token>", "Linear API token")
|
|
35
35
|
.option("--profile <name>", "named profile (under ~/.config/el-linear/profiles/<name>/) for this invocation. Overrides EL_LINEAR_PROFILE env + the on-disk active-profile marker.")
|
|
36
36
|
.option("--json", "output as JSON (default, accepted for compatibility)")
|
|
37
|
+
.option("--format <kind>", "output format: json (default, structured envelope) or summary (human-readable)", "json")
|
|
37
38
|
.option("--raw", "strip { data, meta } wrapper from list output — emit the array directly")
|
|
38
39
|
.option("--jq <filter>", "apply a jq filter to the JSON output")
|
|
39
40
|
.option("--fields <fields>", "filter output to specific fields (comma-separated)")
|
|
@@ -49,6 +50,40 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
|
49
50
|
if (rootOpts.fields) {
|
|
50
51
|
setFieldsFilter(splitList(rootOpts.fields));
|
|
51
52
|
}
|
|
53
|
+
// Subcommand --format wins over the global flag (commander resolves
|
|
54
|
+
// options closest to the action), so `optsWithGlobals` returns the
|
|
55
|
+
// subcommand value when one is set. We accept any of the per-command
|
|
56
|
+
// formats (table/md/csv) silently — those take a different code path
|
|
57
|
+
// inside the handler and never reach outputSuccess. The global
|
|
58
|
+
// behavior only triggers for `summary`.
|
|
59
|
+
const fmt = typeof rootOpts.format === "string"
|
|
60
|
+
? rootOpts.format.toLowerCase()
|
|
61
|
+
: "json";
|
|
62
|
+
if (fmt === "summary") {
|
|
63
|
+
setOutputFormat("summary");
|
|
64
|
+
}
|
|
65
|
+
else if (fmt === "json") {
|
|
66
|
+
setOutputFormat("json");
|
|
67
|
+
}
|
|
68
|
+
else if (fmt === "table" ||
|
|
69
|
+
fmt === "md" ||
|
|
70
|
+
fmt === "markdown" ||
|
|
71
|
+
fmt === "csv") {
|
|
72
|
+
// Per-subcommand format: no global state change. The subcommand
|
|
73
|
+
// owns its own output path.
|
|
74
|
+
setOutputFormat("json");
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
// Match outputSuccess's stable JSON-on-stdout shape so machine
|
|
78
|
+
// callers always get a parseable error envelope on stdout. We
|
|
79
|
+
// can't route through handleAsyncCommand here — preAction runs
|
|
80
|
+
// before the action handler.
|
|
81
|
+
const msg = `Unknown --format: "${rootOpts.format}". Use one of: json, summary` +
|
|
82
|
+
" (per-command formats table/md/csv are also accepted on issues" +
|
|
83
|
+
" list/search and projects list).";
|
|
84
|
+
process.stdout.write(`${JSON.stringify({ error: msg }, null, 2)}\n`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
52
87
|
// `--profile <name>` is highest-priority. preAction runs BEFORE the
|
|
53
88
|
// command body, which is BEFORE getApiToken / loadConfig fire — so
|
|
54
89
|
// setting the override here means the rest of the run picks up the
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed response shapes for the queries in `./attachments.ts`.
|
|
3
|
+
* See `./issues-types.ts` for the rationale (ALL-937).
|
|
4
|
+
*/
|
|
5
|
+
/** Mirrors `ATTACHMENT_FRAGMENT` from `src/queries/attachments.ts`. */
|
|
6
|
+
export interface AttachmentNode {
|
|
7
|
+
id: string;
|
|
8
|
+
title: string | null;
|
|
9
|
+
url: string;
|
|
10
|
+
createdAt: string | null;
|
|
11
|
+
updatedAt: string | null;
|
|
12
|
+
}
|
|
13
|
+
export interface CreateAttachmentResponse {
|
|
14
|
+
attachmentCreate: {
|
|
15
|
+
success: boolean;
|
|
16
|
+
attachment: AttachmentNode | null;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export interface DeleteAttachmentResponse {
|
|
20
|
+
attachmentDelete: {
|
|
21
|
+
success: boolean;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export interface ListAttachmentsResponse {
|
|
25
|
+
issue: {
|
|
26
|
+
attachments: {
|
|
27
|
+
nodes: AttachmentNode[];
|
|
28
|
+
};
|
|
29
|
+
} | null;
|
|
30
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed response shapes for the queries in `./comments.ts`.
|
|
3
|
+
* See `./issues-types.ts` for the rationale (ALL-937).
|
|
4
|
+
*/
|
|
5
|
+
export interface CommentUserRef {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
displayName: string | null;
|
|
9
|
+
url: string | null;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Mirrors the comment selection set used by `LIST_COMMENTS_QUERY`,
|
|
13
|
+
* `CREATE_COMMENT_MUTATION`, and (with `issue` overlay)
|
|
14
|
+
* `UPDATE_COMMENT_MUTATION`.
|
|
15
|
+
*/
|
|
16
|
+
export interface CommentNode {
|
|
17
|
+
id: string;
|
|
18
|
+
body: string;
|
|
19
|
+
createdAt: string;
|
|
20
|
+
updatedAt: string;
|
|
21
|
+
user: CommentUserRef;
|
|
22
|
+
}
|
|
23
|
+
export interface UpdatedCommentNode extends CommentNode {
|
|
24
|
+
issue: {
|
|
25
|
+
id: string;
|
|
26
|
+
identifier: string;
|
|
27
|
+
} | null;
|
|
28
|
+
}
|
|
29
|
+
export interface ListCommentsResponse {
|
|
30
|
+
issue: {
|
|
31
|
+
id: string;
|
|
32
|
+
identifier: string;
|
|
33
|
+
comments: {
|
|
34
|
+
nodes: CommentNode[];
|
|
35
|
+
};
|
|
36
|
+
} | null;
|
|
37
|
+
}
|
|
38
|
+
export interface CreateCommentResponse {
|
|
39
|
+
commentCreate: {
|
|
40
|
+
success: boolean;
|
|
41
|
+
comment: CommentNode | null;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export interface UpdateCommentResponse {
|
|
45
|
+
commentUpdate: {
|
|
46
|
+
success: boolean;
|
|
47
|
+
comment: UpdatedCommentNode | null;
|
|
48
|
+
};
|
|
49
|
+
}
|