@enrichlayer/el-linear 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/dist/auth/oauth-callback.d.ts +40 -0
- package/dist/auth/oauth-callback.js +142 -0
- package/dist/auth/oauth-client.d.ts +55 -0
- package/dist/auth/oauth-client.js +134 -0
- package/dist/auth/oauth-fs.d.ts +1 -0
- package/dist/auth/oauth-fs.js +29 -0
- package/dist/auth/oauth-headless.d.ts +38 -0
- package/dist/auth/oauth-headless.js +50 -0
- package/dist/auth/oauth-storage.d.ts +51 -0
- package/dist/auth/oauth-storage.js +87 -0
- package/dist/auth/oauth-token.d.ts +70 -0
- package/dist/auth/oauth-token.js +141 -0
- package/dist/auth/token-resolver.d.ts +48 -0
- package/dist/auth/token-resolver.js +95 -0
- package/dist/commands/attachments.js +11 -12
- package/dist/commands/batch.js +15 -14
- package/dist/commands/comments.js +22 -10
- package/dist/commands/cycles.js +5 -4
- package/dist/commands/documents.js +18 -15
- package/dist/commands/embeds.js +4 -6
- package/dist/commands/graphql.js +5 -4
- package/dist/commands/init/aliases.js +0 -14
- package/dist/commands/init/defaults.js +0 -6
- package/dist/commands/init/index.js +22 -12
- package/dist/commands/init/oauth.d.ts +85 -0
- package/dist/commands/init/oauth.js +308 -0
- package/dist/commands/init/shared.js +0 -1
- package/dist/commands/init/token.js +0 -7
- package/dist/commands/init/workspace.js +0 -2
- package/dist/commands/issue-id.js +3 -1
- package/dist/commands/issues.js +87 -46
- package/dist/commands/labels.js +9 -8
- package/dist/commands/profile/migrate-legacy.d.ts +96 -0
- package/dist/commands/profile/migrate-legacy.js +271 -0
- package/dist/commands/profile.js +6 -0
- package/dist/commands/project-milestones.js +13 -12
- package/dist/commands/projects.js +18 -14
- package/dist/commands/read-shortcut.js +8 -7
- package/dist/commands/refs.js +4 -3
- package/dist/commands/releases.js +9 -8
- package/dist/commands/search.js +3 -2
- package/dist/commands/teams.js +3 -2
- package/dist/commands/templates.js +5 -4
- package/dist/commands/users.js +3 -2
- package/dist/config/config.d.ts +18 -0
- package/dist/config/issue-validation.js +1 -1
- package/dist/config/resolver.js +1 -1
- package/dist/main.js +1 -1
- package/dist/utils/auth.js +8 -0
- package/dist/utils/download-uploads.d.ts +2 -1
- package/dist/utils/download-uploads.js +2 -4
- package/dist/utils/file-service.d.ts +26 -2
- package/dist/utils/file-service.js +27 -5
- package/dist/utils/footer.d.ts +19 -0
- package/dist/utils/footer.js +27 -0
- package/dist/utils/gdoc-parser.js +1 -1
- package/dist/utils/graphql-attachments-service.d.ts +1 -1
- package/dist/utils/graphql-attachments-service.js +2 -2
- package/dist/utils/graphql-documents-service.d.ts +1 -1
- package/dist/utils/graphql-documents-service.js +2 -2
- package/dist/utils/graphql-service.d.ts +18 -3
- package/dist/utils/graphql-service.js +26 -11
- package/dist/utils/legacy-config-detection.d.ts +47 -0
- package/dist/utils/legacy-config-detection.js +90 -0
- package/dist/utils/linear-service.d.ts +18 -3
- package/dist/utils/linear-service.js +21 -6
- package/dist/utils/markdown-prosemirror.js +16 -9
- package/dist/utils/migration-hint.d.ts +46 -0
- package/dist/utils/migration-hint.js +90 -0
- package/dist/utils/root-opts.d.ts +11 -0
- package/dist/utils/root-opts.js +13 -0
- package/dist/utils/validators.d.ts +7 -0
- package/dist/utils/validators.js +14 -14
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CREATE_RELEASE_MUTATION, GET_RELEASE_BY_ID_QUERY, GET_RELEASE_PIPELINES_QUERY, GET_RELEASES_QUERY, } from "../queries/releases.js";
|
|
2
2
|
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
3
3
|
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
4
|
+
import { getRootOpts } from "../utils/root-opts.js";
|
|
4
5
|
function transformRelease(release) {
|
|
5
6
|
return {
|
|
6
7
|
id: release.id,
|
|
@@ -36,8 +37,8 @@ function transformRelease(release) {
|
|
|
36
37
|
};
|
|
37
38
|
}
|
|
38
39
|
async function handleCreateRelease(name, options, command) {
|
|
39
|
-
const rootOpts = command
|
|
40
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
40
|
+
const rootOpts = getRootOpts(command);
|
|
41
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
41
42
|
const pipelines = await graphQLService.rawRequest(GET_RELEASE_PIPELINES_QUERY, { first: 50 });
|
|
42
43
|
const pipelineNodes = pipelines.releasePipelines
|
|
43
44
|
?.nodes;
|
|
@@ -84,8 +85,8 @@ export function setupReleasesCommands(program) {
|
|
|
84
85
|
.option("--pipeline <pipeline>", "filter by pipeline name or ID")
|
|
85
86
|
.option("-l, --limit <number>", "limit results", "25")
|
|
86
87
|
.action(handleAsyncCommand(async (options, command) => {
|
|
87
|
-
const rootOpts = command
|
|
88
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
88
|
+
const rootOpts = getRootOpts(command);
|
|
89
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
89
90
|
const filter = {};
|
|
90
91
|
if (options.pipeline) {
|
|
91
92
|
filter.pipeline = { name: { eqIgnoreCase: options.pipeline } };
|
|
@@ -102,8 +103,8 @@ export function setupReleasesCommands(program) {
|
|
|
102
103
|
.command("read <releaseId>")
|
|
103
104
|
.description("Get release details including linked documents")
|
|
104
105
|
.action(handleAsyncCommand(async (releaseId, _options, command) => {
|
|
105
|
-
const rootOpts = command
|
|
106
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
106
|
+
const rootOpts = getRootOpts(command);
|
|
107
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
107
108
|
const result = await graphQLService.rawRequest(GET_RELEASE_BY_ID_QUERY, { id: releaseId });
|
|
108
109
|
if (!result.release) {
|
|
109
110
|
throw new Error(`Release "${releaseId}" not found`);
|
|
@@ -122,8 +123,8 @@ export function setupReleasesCommands(program) {
|
|
|
122
123
|
.command("pipelines")
|
|
123
124
|
.description("List release pipelines and their stages")
|
|
124
125
|
.action(handleAsyncCommand(async (_options, command) => {
|
|
125
|
-
const rootOpts = command
|
|
126
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
126
|
+
const rootOpts = getRootOpts(command);
|
|
127
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
127
128
|
const result = await graphQLService.rawRequest(GET_RELEASE_PIPELINES_QUERY, { first: 50 });
|
|
128
129
|
const data = (result.releasePipelines
|
|
129
130
|
?.nodes ?? []).map((p) => ({
|
package/dist/commands/search.js
CHANGED
|
@@ -2,6 +2,7 @@ import { resolveTeam, resolveUserDisplayName } from "../config/resolver.js";
|
|
|
2
2
|
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
|
+
import { getRootOpts } from "../utils/root-opts.js";
|
|
5
6
|
const TEMPLATES_QUERY = `
|
|
6
7
|
query {
|
|
7
8
|
templates {
|
|
@@ -137,8 +138,8 @@ export function setupSearchCommands(program) {
|
|
|
137
138
|
.option("--team <team>", "filter issue results by team key")
|
|
138
139
|
.option("-l, --limit <number>", "max results", "10")
|
|
139
140
|
.action(handleAsyncCommand(async (query, options, command) => {
|
|
140
|
-
const rootOpts = command
|
|
141
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
141
|
+
const rootOpts = getRootOpts(command);
|
|
142
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
142
143
|
const limit = Number.parseInt(options.limit, 10);
|
|
143
144
|
const requestedTypes = options.type
|
|
144
145
|
? options.type.split(",").map((t) => t.trim().toLowerCase())
|
package/dist/commands/teams.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createLinearService } from "../utils/linear-service.js";
|
|
2
2
|
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
3
|
+
import { getRootOpts } from "../utils/root-opts.js";
|
|
3
4
|
export function setupTeamsCommands(program) {
|
|
4
5
|
const teams = program
|
|
5
6
|
.command("teams")
|
|
@@ -11,8 +12,8 @@ export function setupTeamsCommands(program) {
|
|
|
11
12
|
.description("List all teams")
|
|
12
13
|
.option("-l, --limit <number>", "limit results", "100")
|
|
13
14
|
.action(handleAsyncCommand(async (options, command) => {
|
|
14
|
-
const rootOpts = command
|
|
15
|
-
const service = createLinearService(rootOpts);
|
|
15
|
+
const rootOpts = getRootOpts(command);
|
|
16
|
+
const service = await createLinearService(rootOpts);
|
|
16
17
|
const result = await service.getTeams(Number.parseInt(options.limit, 10));
|
|
17
18
|
outputSuccess({ data: result, meta: { count: result.length } });
|
|
18
19
|
}));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { TEMPLATE_BY_ID_QUERY, TEMPLATES_LIST_QUERY, } from "../queries/templates.js";
|
|
2
2
|
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
3
3
|
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
4
|
+
import { getRootOpts } from "../utils/root-opts.js";
|
|
4
5
|
import { parsePositiveInt } from "../utils/validators.js";
|
|
5
6
|
function formatTemplateSummary(t) {
|
|
6
7
|
return {
|
|
@@ -25,8 +26,8 @@ export function setupTemplatesCommands(program) {
|
|
|
25
26
|
.option("--type <type>", "filter by type (issue, document, project, recurringIssue)")
|
|
26
27
|
.option("-l, --limit <number>", "limit results", "50")
|
|
27
28
|
.action(handleAsyncCommand(async (options, command) => {
|
|
28
|
-
const rootOpts = command
|
|
29
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
29
|
+
const rootOpts = getRootOpts(command);
|
|
30
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
30
31
|
const limit = parsePositiveInt(options.limit, "--limit");
|
|
31
32
|
const result = await graphQLService.rawRequest(TEMPLATES_LIST_QUERY);
|
|
32
33
|
let items = result.templates ?? [];
|
|
@@ -44,8 +45,8 @@ export function setupTemplatesCommands(program) {
|
|
|
44
45
|
.command("read <templateId>")
|
|
45
46
|
.description("Read a template including its full content (templateData)")
|
|
46
47
|
.action(handleAsyncCommand(async (templateId, _options, command) => {
|
|
47
|
-
const rootOpts = command
|
|
48
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
48
|
+
const rootOpts = getRootOpts(command);
|
|
49
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
49
50
|
const result = await graphQLService.rawRequest(TEMPLATE_BY_ID_QUERY, {
|
|
50
51
|
id: templateId,
|
|
51
52
|
});
|
package/dist/commands/users.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createLinearService } from "../utils/linear-service.js";
|
|
2
2
|
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
3
|
+
import { getRootOpts } from "../utils/root-opts.js";
|
|
3
4
|
export function setupUsersCommands(program) {
|
|
4
5
|
const users = program.command("users").description("User operations");
|
|
5
6
|
users.action(() => users.help());
|
|
@@ -9,8 +10,8 @@ export function setupUsersCommands(program) {
|
|
|
9
10
|
.option("--active", "Only show active users")
|
|
10
11
|
.option("-l, --limit <number>", "limit results", "100")
|
|
11
12
|
.action(handleAsyncCommand(async (options, command) => {
|
|
12
|
-
const rootOpts = command
|
|
13
|
-
const service = createLinearService(rootOpts);
|
|
13
|
+
const rootOpts = getRootOpts(command);
|
|
14
|
+
const service = await createLinearService(rootOpts);
|
|
14
15
|
const result = await service.getUsers(options.active, Number.parseInt(options.limit, 10));
|
|
15
16
|
outputSuccess({ data: result, meta: { count: result.length } });
|
|
16
17
|
}));
|
package/dist/config/config.d.ts
CHANGED
|
@@ -39,6 +39,24 @@ export interface ElLinearConfig {
|
|
|
39
39
|
* once per session and caches the result.
|
|
40
40
|
*/
|
|
41
41
|
workspaceUrlKey?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Text appended to issue descriptions and comment bodies on the `create`
|
|
44
|
+
* paths. Treated as a literal string — include any `\n\n---\n` separator
|
|
45
|
+
* yourself if you want a horizontal rule. CLI flags `--footer <text>` and
|
|
46
|
+
* `--no-footer` override this per-invocation.
|
|
47
|
+
*/
|
|
48
|
+
messageFooter?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Named description boilerplates for `el-linear issues create --template <name>`.
|
|
51
|
+
* The template's value is used as the description body when `--template` is
|
|
52
|
+
* provided and neither `--description` nor `--description-file` is set.
|
|
53
|
+
*
|
|
54
|
+
* Example:
|
|
55
|
+
* "descriptionTemplates": {
|
|
56
|
+
* "bug": "## Steps to reproduce\n\n1. ...\n\n## Expected\n\n...\n\n## Actual\n\n..."
|
|
57
|
+
* }
|
|
58
|
+
*/
|
|
59
|
+
descriptionTemplates?: Record<string, string>;
|
|
42
60
|
}
|
|
43
61
|
/** Test seam — resets the cache between test cases. */
|
|
44
62
|
export declare function _resetConfigCacheForTests(): void;
|
|
@@ -210,7 +210,7 @@ function checkTitleVerbAlignment(title, typeLabel, result) {
|
|
|
210
210
|
for (const verb of typeVerbs) {
|
|
211
211
|
if (!verb.includes(" "))
|
|
212
212
|
continue;
|
|
213
|
-
if (title.toLowerCase().startsWith(verb.toLowerCase()
|
|
213
|
+
if (title.toLowerCase().startsWith(`${verb.toLowerCase()} `) ||
|
|
214
214
|
title.toLowerCase() === verb.toLowerCase()) {
|
|
215
215
|
if (type === typeLabel)
|
|
216
216
|
return; // match — all good
|
package/dist/config/resolver.js
CHANGED
|
@@ -91,7 +91,7 @@ export function resolveMember(input) {
|
|
|
91
91
|
*/
|
|
92
92
|
export async function resolveAssignee(input, rootOpts) {
|
|
93
93
|
if (input.toLowerCase() === "me") {
|
|
94
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
94
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
95
95
|
const result = await graphQLService.rawRequest("{ viewer { id } }");
|
|
96
96
|
const viewer = result.viewer;
|
|
97
97
|
if (!viewer?.id) {
|
package/dist/main.js
CHANGED
|
@@ -30,7 +30,7 @@ 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.6.0")
|
|
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)")
|
package/dist/utils/auth.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { LEGACY_LINCTL_TOKEN_PATH, LEGACY_TOKEN_PATH, resolveActiveProfile, TOKEN_PATH, } from "../config/paths.js";
|
|
3
|
+
import { maybeEmitMigrationHint } from "./migration-hint.js";
|
|
3
4
|
export function getApiToken(options) {
|
|
4
5
|
if (options.apiToken) {
|
|
5
6
|
return options.apiToken;
|
|
@@ -30,6 +31,13 @@ export function getApiToken(options) {
|
|
|
30
31
|
if (fs.existsSync(LEGACY_TOKEN_PATH)) {
|
|
31
32
|
return fs.readFileSync(LEGACY_TOKEN_PATH, "utf8").trim();
|
|
32
33
|
}
|
|
34
|
+
// Before falling through to the auth error, check for legacy-config
|
|
35
|
+
// drift (legacy `config.json` present but no token, or active-profile
|
|
36
|
+
// pointer broken). If detected, emit a one-shot stderr hint pointing
|
|
37
|
+
// the user at `el-linear profile migrate-legacy`. The hint is purely
|
|
38
|
+
// informational — we always still throw below, so scripted callers
|
|
39
|
+
// continue to see a non-zero exit and a parseable JSON error on stdout.
|
|
40
|
+
maybeEmitMigrationHint();
|
|
33
41
|
const profileNote = active.name
|
|
34
42
|
? ` (active profile: \`${active.name}\` — expected token at ${active.tokenPath})`
|
|
35
43
|
: "";
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { LinearIssue } from "../types/linear.js";
|
|
2
|
+
import type { FileService } from "./file-service.js";
|
|
2
3
|
/**
|
|
3
4
|
* Downloads all uploads.linear.app URLs found in an issue's description
|
|
4
5
|
* and comments, replacing them with local file paths so Claude Code
|
|
5
6
|
* can read the images directly.
|
|
6
7
|
*/
|
|
7
|
-
export declare function downloadLinearUploads(issue: LinearIssue,
|
|
8
|
+
export declare function downloadLinearUploads(issue: LinearIssue, fileService: FileService): Promise<LinearIssue>;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { mkdir } from "node:fs/promises";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { FileService } from "./file-service.js";
|
|
5
4
|
const DOWNLOAD_DIR = join(tmpdir(), "el-linear-downloads");
|
|
6
5
|
const UPLOAD_URL_REGEX = /https:\/\/uploads\.linear\.app\/[^\s)>\]"]+/g;
|
|
7
6
|
function collectUploadUrls(text) {
|
|
@@ -20,7 +19,7 @@ function replaceUrls(text, urlMap) {
|
|
|
20
19
|
* and comments, replacing them with local file paths so Claude Code
|
|
21
20
|
* can read the images directly.
|
|
22
21
|
*/
|
|
23
|
-
export async function downloadLinearUploads(issue,
|
|
22
|
+
export async function downloadLinearUploads(issue, fileService) {
|
|
24
23
|
const allUrls = new Set();
|
|
25
24
|
if (issue.description) {
|
|
26
25
|
for (const url of collectUploadUrls(issue.description)) {
|
|
@@ -38,7 +37,6 @@ export async function downloadLinearUploads(issue, apiToken) {
|
|
|
38
37
|
return issue;
|
|
39
38
|
}
|
|
40
39
|
await mkdir(DOWNLOAD_DIR, { recursive: true });
|
|
41
|
-
const fileService = new FileService(apiToken);
|
|
42
40
|
const urlMap = new Map();
|
|
43
41
|
await Promise.all([...allUrls].map(async (url) => {
|
|
44
42
|
const filename = extractUniqueFilename(url);
|
|
@@ -78,7 +76,7 @@ function extractUniqueFilename(url) {
|
|
|
78
76
|
// Path like /workspace-id/uuid1/uuid2/filename.png — take last two parts for uniqueness
|
|
79
77
|
const parts = urlObj.pathname.split("/").filter(Boolean);
|
|
80
78
|
if (parts.length >= 2) {
|
|
81
|
-
return `${parts.at(-2)
|
|
79
|
+
return `${parts.at(-2)?.slice(0, 8)}-${parts.at(-1) ?? ""}`;
|
|
82
80
|
}
|
|
83
81
|
return parts.at(-1) || "download";
|
|
84
82
|
}
|
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import type { FileDownloadResult, FileUploadResult } from "../types/linear.js";
|
|
2
|
+
/**
|
|
3
|
+
* Constructor arg shapes for `FileService`:
|
|
4
|
+
* - `string` → personal API token (legacy; sent as `Authorization: <token>`).
|
|
5
|
+
* - `{apiKey: string}` → personal API token (explicit).
|
|
6
|
+
* - `{oauthToken: string}` → OAuth access token (sent as
|
|
7
|
+
* `Authorization: Bearer <token>`).
|
|
8
|
+
*
|
|
9
|
+
* The string variant exists because dozens of tests construct `FileService`
|
|
10
|
+
* with a plain string. We continue to support it indefinitely.
|
|
11
|
+
*/
|
|
12
|
+
export type FileServiceAuth = string | {
|
|
13
|
+
apiKey: string;
|
|
14
|
+
} | {
|
|
15
|
+
oauthToken: string;
|
|
16
|
+
};
|
|
2
17
|
export declare class FileService {
|
|
3
|
-
private readonly
|
|
4
|
-
constructor(
|
|
18
|
+
private readonly authHeader;
|
|
19
|
+
constructor(auth: FileServiceAuth);
|
|
5
20
|
downloadFile(url: string, options?: {
|
|
6
21
|
output?: string;
|
|
7
22
|
overwrite?: boolean;
|
|
@@ -11,3 +26,12 @@ export declare class FileService {
|
|
|
11
26
|
private requestUploadUrl;
|
|
12
27
|
private putFileToUrl;
|
|
13
28
|
}
|
|
29
|
+
import type { AuthOptions } from "./auth.js";
|
|
30
|
+
/**
|
|
31
|
+
* OAuth-aware factory: resolves the active credential (with auto-refresh
|
|
32
|
+
* for OAuth tokens) and returns a `FileService` configured with the right
|
|
33
|
+
* `Authorization` header shape. Personal tokens use `Authorization:
|
|
34
|
+
* <token>` (no Bearer prefix); OAuth tokens use `Authorization: Bearer
|
|
35
|
+
* <token>`.
|
|
36
|
+
*/
|
|
37
|
+
export declare function createFileService(options: AuthOptions): Promise<FileService>;
|
|
@@ -37,10 +37,17 @@ function getMimeType(filePath) {
|
|
|
37
37
|
const ext = extname(filePath).toLowerCase();
|
|
38
38
|
return MIME_TYPES[ext] || "application/octet-stream";
|
|
39
39
|
}
|
|
40
|
+
function buildAuthHeader(auth) {
|
|
41
|
+
if (typeof auth === "string")
|
|
42
|
+
return auth;
|
|
43
|
+
if ("oauthToken" in auth)
|
|
44
|
+
return `Bearer ${auth.oauthToken}`;
|
|
45
|
+
return auth.apiKey;
|
|
46
|
+
}
|
|
40
47
|
export class FileService {
|
|
41
|
-
|
|
42
|
-
constructor(
|
|
43
|
-
this.
|
|
48
|
+
authHeader;
|
|
49
|
+
constructor(auth) {
|
|
50
|
+
this.authHeader = buildAuthHeader(auth);
|
|
44
51
|
}
|
|
45
52
|
async downloadFile(url, options = {}) {
|
|
46
53
|
if (!isLinearUploadUrl(url)) {
|
|
@@ -67,7 +74,7 @@ export class FileService {
|
|
|
67
74
|
const isSignedUrl = urlObj.searchParams.has("signature");
|
|
68
75
|
const headers = {};
|
|
69
76
|
if (!isSignedUrl) {
|
|
70
|
-
headers.Authorization = this.
|
|
77
|
+
headers.Authorization = this.authHeader;
|
|
71
78
|
}
|
|
72
79
|
const response = await fetch(url, { method: "GET", headers });
|
|
73
80
|
if (!response.ok) {
|
|
@@ -162,7 +169,7 @@ export class FileService {
|
|
|
162
169
|
method: "POST",
|
|
163
170
|
headers: {
|
|
164
171
|
"Content-Type": "application/json",
|
|
165
|
-
Authorization: this.
|
|
172
|
+
Authorization: this.authHeader,
|
|
166
173
|
},
|
|
167
174
|
body: JSON.stringify({
|
|
168
175
|
query,
|
|
@@ -237,3 +244,18 @@ export class FileService {
|
|
|
237
244
|
return { success: true, assetUrl: meta.assetUrl, filename };
|
|
238
245
|
}
|
|
239
246
|
}
|
|
247
|
+
import { getActiveAuth } from "../auth/token-resolver.js";
|
|
248
|
+
/**
|
|
249
|
+
* OAuth-aware factory: resolves the active credential (with auto-refresh
|
|
250
|
+
* for OAuth tokens) and returns a `FileService` configured with the right
|
|
251
|
+
* `Authorization` header shape. Personal tokens use `Authorization:
|
|
252
|
+
* <token>` (no Bearer prefix); OAuth tokens use `Authorization: Bearer
|
|
253
|
+
* <token>`.
|
|
254
|
+
*/
|
|
255
|
+
export async function createFileService(options) {
|
|
256
|
+
const auth = await getActiveAuth(options);
|
|
257
|
+
if (auth.kind === "oauth") {
|
|
258
|
+
return new FileService({ oauthToken: auth.token });
|
|
259
|
+
}
|
|
260
|
+
return new FileService({ apiKey: auth.token });
|
|
261
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append a footer to a body string.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order:
|
|
5
|
+
* 1. `--no-footer` flag → return body unchanged
|
|
6
|
+
* 2. `--footer <text>` flag → use the explicit value
|
|
7
|
+
* 3. `config.messageFooter` → use the configured value
|
|
8
|
+
* 4. nothing set → return body unchanged
|
|
9
|
+
*
|
|
10
|
+
* The footer is treated as a literal string — callers who want a horizontal
|
|
11
|
+
* rule or blank line before it must include the separator in the value.
|
|
12
|
+
*
|
|
13
|
+
* Used on `create` paths only (issues create, comments create). Update paths
|
|
14
|
+
* take an explicit body the user has already authored, so we don't auto-inject.
|
|
15
|
+
*/
|
|
16
|
+
export declare function applyFooter(body: string | undefined, options: {
|
|
17
|
+
footer?: string;
|
|
18
|
+
noFooter?: boolean;
|
|
19
|
+
}): string | undefined;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { loadConfig } from "../config/config.js";
|
|
2
|
+
/**
|
|
3
|
+
* Append a footer to a body string.
|
|
4
|
+
*
|
|
5
|
+
* Resolution order:
|
|
6
|
+
* 1. `--no-footer` flag → return body unchanged
|
|
7
|
+
* 2. `--footer <text>` flag → use the explicit value
|
|
8
|
+
* 3. `config.messageFooter` → use the configured value
|
|
9
|
+
* 4. nothing set → return body unchanged
|
|
10
|
+
*
|
|
11
|
+
* The footer is treated as a literal string — callers who want a horizontal
|
|
12
|
+
* rule or blank line before it must include the separator in the value.
|
|
13
|
+
*
|
|
14
|
+
* Used on `create` paths only (issues create, comments create). Update paths
|
|
15
|
+
* take an explicit body the user has already authored, so we don't auto-inject.
|
|
16
|
+
*/
|
|
17
|
+
export function applyFooter(body, options) {
|
|
18
|
+
if (options.noFooter) {
|
|
19
|
+
return body;
|
|
20
|
+
}
|
|
21
|
+
const explicit = options.footer;
|
|
22
|
+
const footer = explicit ?? loadConfig().messageFooter;
|
|
23
|
+
if (!footer) {
|
|
24
|
+
return body;
|
|
25
|
+
}
|
|
26
|
+
return body ? `${body}${footer}` : footer;
|
|
27
|
+
}
|
|
@@ -82,7 +82,7 @@ export function parseGoogleDoc(doc) {
|
|
|
82
82
|
.filter((c) => c.paragraph)
|
|
83
83
|
.map((c) => {
|
|
84
84
|
let text = "";
|
|
85
|
-
for (const el of c.paragraph
|
|
85
|
+
for (const el of c.paragraph?.elements ?? []) {
|
|
86
86
|
text += formatTextRun(el);
|
|
87
87
|
}
|
|
88
88
|
return text.replace(TRAILING_NEWLINE_RE, "");
|
|
@@ -8,5 +8,5 @@ declare class GraphQLAttachmentsService {
|
|
|
8
8
|
deleteAttachment(id: string): Promise<boolean>;
|
|
9
9
|
listAttachments(issueId: string): Promise<LinearAttachment[]>;
|
|
10
10
|
}
|
|
11
|
-
export declare function createGraphQLAttachmentsService(options: AuthOptions): GraphQLAttachmentsService
|
|
11
|
+
export declare function createGraphQLAttachmentsService(options: AuthOptions): Promise<GraphQLAttachmentsService>;
|
|
12
12
|
export {};
|
|
@@ -40,7 +40,7 @@ class GraphQLAttachmentsService {
|
|
|
40
40
|
return attachments.nodes.map(transformAttachment);
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
|
-
export function createGraphQLAttachmentsService(options) {
|
|
44
|
-
const graphqlService = createGraphQLService(options);
|
|
43
|
+
export async function createGraphQLAttachmentsService(options) {
|
|
44
|
+
const graphqlService = await createGraphQLService(options);
|
|
45
45
|
return new GraphQLAttachmentsService(graphqlService);
|
|
46
46
|
}
|
|
@@ -14,5 +14,5 @@ declare class GraphQLDocumentsService {
|
|
|
14
14
|
deleteDocument(id: string): Promise<boolean>;
|
|
15
15
|
listDocumentsBySlugIds(slugIds: string[], limit?: number): Promise<LinearDocument[]>;
|
|
16
16
|
}
|
|
17
|
-
export declare function createGraphQLDocumentsService(options: AuthOptions): GraphQLDocumentsService
|
|
17
|
+
export declare function createGraphQLDocumentsService(options: AuthOptions): Promise<GraphQLDocumentsService>;
|
|
18
18
|
export {};
|
|
@@ -91,7 +91,7 @@ class GraphQLDocumentsService {
|
|
|
91
91
|
return result.documents.nodes.map(transformDocument);
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
-
export function createGraphQLDocumentsService(options) {
|
|
95
|
-
const graphqlService = createGraphQLService(options);
|
|
94
|
+
export async function createGraphQLDocumentsService(options) {
|
|
95
|
+
const graphqlService = await createGraphQLService(options);
|
|
96
96
|
return new GraphQLDocumentsService(graphqlService);
|
|
97
97
|
}
|
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
import type { GraphQLResponseData, GraphQLVariables } from "../types/linear.js";
|
|
2
|
-
import {
|
|
2
|
+
import type { AuthOptions } from "./auth.js";
|
|
3
|
+
/**
|
|
4
|
+
* Constructor arg shapes for `GraphQLService`. Three variants:
|
|
5
|
+
* - `string` → personal API token (legacy; sent without `Bearer` prefix).
|
|
6
|
+
* - `{apiKey: string}` → personal API token (explicit).
|
|
7
|
+
* - `{oauthToken: string}` → OAuth access token (sent as
|
|
8
|
+
* `Authorization: Bearer <token>` via the SDK's accessToken option).
|
|
9
|
+
*
|
|
10
|
+
* The string variant exists because hundreds of call sites and tests pass
|
|
11
|
+
* a plain string. We continue to support it indefinitely.
|
|
12
|
+
*/
|
|
13
|
+
export type GraphQLServiceAuth = string | {
|
|
14
|
+
apiKey: string;
|
|
15
|
+
} | {
|
|
16
|
+
oauthToken: string;
|
|
17
|
+
};
|
|
3
18
|
export declare class GraphQLService {
|
|
4
19
|
private readonly graphQLClient;
|
|
5
|
-
constructor(
|
|
20
|
+
constructor(auth: GraphQLServiceAuth);
|
|
6
21
|
rawRequest<T = GraphQLResponseData>(query: string, variables?: GraphQLVariables): Promise<T>;
|
|
7
22
|
}
|
|
8
|
-
export declare function createGraphQLService(options: AuthOptions): GraphQLService
|
|
23
|
+
export declare function createGraphQLService(options: AuthOptions): Promise<GraphQLService>;
|
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
import { LinearClient } from "@linear/sdk";
|
|
2
|
-
import {
|
|
2
|
+
import { getActiveAuth } from "../auth/token-resolver.js";
|
|
3
|
+
function buildLinearClient(auth) {
|
|
4
|
+
const baseHeaders = { "public-file-urls-expire-in": "3600" };
|
|
5
|
+
if (typeof auth === "string") {
|
|
6
|
+
return new LinearClient({ apiKey: auth, headers: baseHeaders });
|
|
7
|
+
}
|
|
8
|
+
if ("oauthToken" in auth) {
|
|
9
|
+
// Linear's SDK natively supports OAuth via the `accessToken` option,
|
|
10
|
+
// which causes the underlying graphql-request client to send
|
|
11
|
+
// `Authorization: Bearer <token>` instead of the personal-token
|
|
12
|
+
// shape (`Authorization: <token>`).
|
|
13
|
+
return new LinearClient({
|
|
14
|
+
accessToken: auth.oauthToken,
|
|
15
|
+
headers: baseHeaders,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return new LinearClient({ apiKey: auth.apiKey, headers: baseHeaders });
|
|
19
|
+
}
|
|
3
20
|
export class GraphQLService {
|
|
4
21
|
graphQLClient;
|
|
5
|
-
constructor(
|
|
6
|
-
const client =
|
|
7
|
-
apiKey: apiToken,
|
|
8
|
-
headers: {
|
|
9
|
-
"public-file-urls-expire-in": "3600",
|
|
10
|
-
},
|
|
11
|
-
});
|
|
22
|
+
constructor(auth) {
|
|
23
|
+
const client = buildLinearClient(auth);
|
|
12
24
|
// LinearClient stores a private graphql-request client — access via escape hatch
|
|
13
25
|
this.graphQLClient = client.client;
|
|
14
26
|
}
|
|
@@ -29,7 +41,10 @@ export class GraphQLService {
|
|
|
29
41
|
}
|
|
30
42
|
}
|
|
31
43
|
}
|
|
32
|
-
export function createGraphQLService(options) {
|
|
33
|
-
const
|
|
34
|
-
|
|
44
|
+
export async function createGraphQLService(options) {
|
|
45
|
+
const auth = await getActiveAuth(options);
|
|
46
|
+
if (auth.kind === "oauth") {
|
|
47
|
+
return new GraphQLService({ oauthToken: auth.token });
|
|
48
|
+
}
|
|
49
|
+
return new GraphQLService({ apiKey: auth.token });
|
|
35
50
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects "legacy drift" — the on-disk state where a user upgraded el-linear
|
|
3
|
+
* to >=1.4.0 (named profiles) while their old `~/.config/el-linear/config.json`
|
|
4
|
+
* is still present but the legacy single-file `token` (and any per-profile
|
|
5
|
+
* token) is missing or unreadable. In that state every command fails with
|
|
6
|
+
* "No API token found" and the user has no clear migration path.
|
|
7
|
+
*
|
|
8
|
+
* This module is **pure detection** — it returns a discriminated state and
|
|
9
|
+
* nothing else. The hint emission lives in `migration-hint.ts` and is wired
|
|
10
|
+
* into the auth-failure path (`auth.ts`) so the user gets a single clear
|
|
11
|
+
* stderr line *before* the regular auth error fires.
|
|
12
|
+
*
|
|
13
|
+
* The state shape is intentionally a discriminated union so callers can match
|
|
14
|
+
* exhaustively without re-checking individual booleans:
|
|
15
|
+
*
|
|
16
|
+
* { kind: 'no-drift' }
|
|
17
|
+
* — healthy: legacy single-file layout *or* a working active profile.
|
|
18
|
+
*
|
|
19
|
+
* { kind: 'legacy-no-token' }
|
|
20
|
+
* — `config.json` exists but no token (legacy or per-profile) does. This
|
|
21
|
+
* is the post-upgrade case: 1.4.0 expects per-profile tokens; legacy
|
|
22
|
+
* config was never migrated.
|
|
23
|
+
*
|
|
24
|
+
* { kind: 'broken-active-profile' }
|
|
25
|
+
* — `active-profile` points at a name whose directory doesn't exist.
|
|
26
|
+
* Typically caused by an interrupted `profile remove` or a hand-edit.
|
|
27
|
+
*/
|
|
28
|
+
export type LegacyDriftState = {
|
|
29
|
+
kind: "no-drift";
|
|
30
|
+
} | {
|
|
31
|
+
kind: "legacy-no-token";
|
|
32
|
+
legacyConfigPath: string;
|
|
33
|
+
} | {
|
|
34
|
+
kind: "broken-active-profile";
|
|
35
|
+
pointedAt: string;
|
|
36
|
+
};
|
|
37
|
+
export interface DetectionFsOps {
|
|
38
|
+
existsSync: (p: string) => boolean;
|
|
39
|
+
readFileSync: (p: string) => string;
|
|
40
|
+
readdirSync: (p: string) => string[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Detect drift between the legacy single-file layout and the >=1.4 named-
|
|
44
|
+
* profiles layout. Pure — `fsImpl` is overridable so tests can drive every
|
|
45
|
+
* branch without touching the filesystem.
|
|
46
|
+
*/
|
|
47
|
+
export declare function detectLegacyDrift(fsImpl?: DetectionFsOps): LegacyDriftState;
|