@enrichlayer/el-linear 1.5.0 → 1.7.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 +32 -4
- package/dist/auth/oauth-app-config.d.ts +21 -0
- package/dist/auth/oauth-app-config.js +82 -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.d.ts +18 -1
- package/dist/commands/init/defaults.js +83 -8
- package/dist/commands/init/index.js +7 -13
- package/dist/commands/init/oauth.d.ts +4 -4
- package/dist/commands/init/oauth.js +24 -6
- 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 +130 -54
- package/dist/commands/labels.js +23 -9
- package/dist/commands/profile/migrate-legacy.js +0 -1
- package/dist/commands/project-milestones.js +13 -12
- package/dist/commands/projects.js +28 -15
- 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 +14 -3
- package/dist/commands/templates.js +5 -4
- package/dist/commands/users.js +3 -2
- package/dist/config/config.d.ts +37 -0
- package/dist/config/issue-validation.js +1 -1
- package/dist/config/paths.d.ts +1 -0
- package/dist/config/paths.js +1 -0
- package/dist/config/resolver.js +1 -1
- package/dist/main.js +3 -2
- package/dist/utils/disk-cache.d.ts +51 -0
- package/dist/utils/disk-cache.js +178 -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 +2 -2
- package/dist/utils/graphql-service.js +7 -4
- 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/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
package/dist/commands/refs.js
CHANGED
|
@@ -4,6 +4,7 @@ import { extractIssueReferences } from "../utils/issue-reference-extractor.js";
|
|
|
4
4
|
import { wrapIssueReferencesAsLinks, } from "../utils/issue-reference-wrapper.js";
|
|
5
5
|
import { createLinearService } from "../utils/linear-service.js";
|
|
6
6
|
import { handleAsyncCommand } from "../utils/output.js";
|
|
7
|
+
import { getRootOpts } from "../utils/root-opts.js";
|
|
7
8
|
import { validateReferences } from "../utils/validate-references.js";
|
|
8
9
|
import { getWorkspaceUrlKey } from "../utils/workspace-url.js";
|
|
9
10
|
const VALID_TARGETS = new Set(["markdown", "slack"]);
|
|
@@ -58,15 +59,15 @@ async function handleWrap(options, command) {
|
|
|
58
59
|
const text = typeof options.file === "string" && options.file.length > 0
|
|
59
60
|
? readFileSync(options.file, "utf8")
|
|
60
61
|
: await readAllStdin();
|
|
61
|
-
const rootOpts = command
|
|
62
|
+
const rootOpts = getRootOpts(command);
|
|
62
63
|
const deps = {
|
|
63
64
|
async resolveValidIdentifiers(ids) {
|
|
64
|
-
const linearService = createLinearService(rootOpts);
|
|
65
|
+
const linearService = await createLinearService(rootOpts);
|
|
65
66
|
const map = await validateReferences(ids, linearService);
|
|
66
67
|
return new Set(map.keys());
|
|
67
68
|
},
|
|
68
69
|
async resolveUrlKey() {
|
|
69
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
70
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
70
71
|
return getWorkspaceUrlKey(graphQLService);
|
|
71
72
|
},
|
|
72
73
|
};
|
|
@@ -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,8 @@
|
|
|
1
|
+
import { loadConfig } from "../config/config.js";
|
|
2
|
+
import { cached, resolveCacheTTL } from "../utils/disk-cache.js";
|
|
1
3
|
import { createLinearService } from "../utils/linear-service.js";
|
|
2
4
|
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
5
|
+
import { getRootOpts } from "../utils/root-opts.js";
|
|
3
6
|
export function setupTeamsCommands(program) {
|
|
4
7
|
const teams = program
|
|
5
8
|
.command("teams")
|
|
@@ -11,9 +14,17 @@ export function setupTeamsCommands(program) {
|
|
|
11
14
|
.description("List all teams")
|
|
12
15
|
.option("-l, --limit <number>", "limit results", "100")
|
|
13
16
|
.action(handleAsyncCommand(async (options, command) => {
|
|
14
|
-
const rootOpts = command
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
+
const rootOpts = getRootOpts(command);
|
|
18
|
+
const limit = Number.parseInt(options.limit, 10);
|
|
19
|
+
const ttl = resolveCacheTTL({
|
|
20
|
+
configTTL: loadConfig().cacheTTLSeconds,
|
|
21
|
+
// commander's `--no-cache` produces `cache: false` on the root opts.
|
|
22
|
+
noCacheFlag: rootOpts.cache === false,
|
|
23
|
+
});
|
|
24
|
+
const result = await cached(`teams-list-limit:${limit}`, ttl, async () => {
|
|
25
|
+
const service = await createLinearService(rootOpts);
|
|
26
|
+
return service.getTeams(limit);
|
|
27
|
+
});
|
|
17
28
|
outputSuccess({ data: result, meta: { count: result.length } });
|
|
18
29
|
}));
|
|
19
30
|
}
|
|
@@ -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,43 @@ 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>;
|
|
60
|
+
/**
|
|
61
|
+
* Default assignee identifier (alias / display name / email / UUID — same
|
|
62
|
+
* shapes resolveAssignee accepts) for `issues create`. Applied when
|
|
63
|
+
* `--assignee` is not passed. Pass `--no-assignee` to override at one site.
|
|
64
|
+
*/
|
|
65
|
+
defaultAssignee?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Default priority for `issues create` / `issues update`. Accepts the same
|
|
68
|
+
* keywords as the --priority flag: `none|urgent|high|medium|normal|low`
|
|
69
|
+
* or `0`–`4`. Applied when `--priority` is not passed.
|
|
70
|
+
*/
|
|
71
|
+
defaultPriority?: string;
|
|
72
|
+
/**
|
|
73
|
+
* TTL (seconds) for the on-disk cache used by `teams list`, `labels list`,
|
|
74
|
+
* and `projects list`. Defaults to 3600 (1 hour) when omitted. A value of
|
|
75
|
+
* `0` disables the cache entirely. Override per-invocation with
|
|
76
|
+
* `--no-cache`.
|
|
77
|
+
*/
|
|
78
|
+
cacheTTLSeconds?: number;
|
|
42
79
|
}
|
|
43
80
|
/** Test seam — resets the cache between test cases. */
|
|
44
81
|
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/paths.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
export declare const CONFIG_DIR: string;
|
|
7
7
|
export declare const CONFIG_PATH: string;
|
|
8
8
|
export declare const TOKEN_PATH: string;
|
|
9
|
+
export declare const TEAM_OAUTH_CONFIG_PATH: string;
|
|
9
10
|
export declare const ALIASES_PROGRESS_PATH: string;
|
|
10
11
|
/**
|
|
11
12
|
* Legacy fallback paths kept for backward compatibility. The CLI was briefly
|
package/dist/config/paths.js
CHANGED
|
@@ -9,6 +9,7 @@ import path from "node:path";
|
|
|
9
9
|
export const CONFIG_DIR = path.join(os.homedir(), ".config", "el-linear");
|
|
10
10
|
export const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
11
11
|
export const TOKEN_PATH = path.join(CONFIG_DIR, "token");
|
|
12
|
+
export const TEAM_OAUTH_CONFIG_PATH = path.join(CONFIG_DIR, "team-oauth.json");
|
|
12
13
|
export const ALIASES_PROGRESS_PATH = path.join(CONFIG_DIR, ".init-aliases-progress");
|
|
13
14
|
/**
|
|
14
15
|
* Legacy fallback paths kept for backward compatibility. The CLI was briefly
|
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,13 +30,14 @@ 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.7.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)")
|
|
37
37
|
.option("--raw", "strip { data, meta } wrapper from list output — emit the array directly")
|
|
38
38
|
.option("--jq <filter>", "apply a jq filter to the JSON output")
|
|
39
|
-
.option("--fields <fields>", "filter output to specific fields (comma-separated)")
|
|
39
|
+
.option("--fields <fields>", "filter output to specific fields (comma-separated)")
|
|
40
|
+
.option("--no-cache", "bypass the on-disk cache for `teams list` / `labels list` / `projects list`");
|
|
40
41
|
program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
41
42
|
const rootOpts = actionCommand.optsWithGlobals();
|
|
42
43
|
if (rootOpts.raw) {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile-aware disk cache with TTL. Stores JSON envelopes at
|
|
3
|
+
* `<profile-dir>/cache/<key>.json`:
|
|
4
|
+
*
|
|
5
|
+
* { v: 1, key, fetchedAt, expiresAt, data }
|
|
6
|
+
*
|
|
7
|
+
* `cached(key, ttlSeconds, fetcher)`:
|
|
8
|
+
* - returns cached `data` when expiresAt > now
|
|
9
|
+
* - else awaits fetcher, writes envelope, returns fresh data
|
|
10
|
+
* - on read error (corrupt JSON, missing dir), silently refetches
|
|
11
|
+
*
|
|
12
|
+
* Bypass:
|
|
13
|
+
* - `bypass: true` option always refetches and rewrites (used by --no-cache)
|
|
14
|
+
* - any error during write is logged via stderr but doesn't fail the call
|
|
15
|
+
*
|
|
16
|
+
* Eviction:
|
|
17
|
+
* - no automatic eviction; old keys stay until manually cleared
|
|
18
|
+
* - `clearCache(prefix?)` for tests + a future `el-linear cache clear` command
|
|
19
|
+
*
|
|
20
|
+
* Path:
|
|
21
|
+
* - profile-aware via resolveActiveProfile() — caches don't bleed between
|
|
22
|
+
* profiles
|
|
23
|
+
*/
|
|
24
|
+
export interface CacheOptions {
|
|
25
|
+
/** When true, skip the read step and always refetch + rewrite. */
|
|
26
|
+
bypass?: boolean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Read-through cache wrapper.
|
|
30
|
+
*
|
|
31
|
+
* `key` — stable string identifier including any filter params
|
|
32
|
+
* (e.g. `teams-list`, `labels-list-team:ENG`)
|
|
33
|
+
* `ttlSeconds` — lifetime; `0` disables the cache entirely (always
|
|
34
|
+
* refetch, never write).
|
|
35
|
+
* `fetcher` — async function returning the fresh data on a miss.
|
|
36
|
+
* `options.bypass` — force a refetch even when an unexpired envelope
|
|
37
|
+
* exists. Still rewrites on success.
|
|
38
|
+
*/
|
|
39
|
+
export declare function cached<T>(key: string, ttlSeconds: number, fetcher: () => Promise<T>, options?: CacheOptions): Promise<T>;
|
|
40
|
+
/**
|
|
41
|
+
* Clear cached entries. With no `prefix`, removes the entire cache
|
|
42
|
+
* directory. With a `prefix`, only deletes envelopes whose sanitized key
|
|
43
|
+
* starts with it. Errors (missing dir, permission) are swallowed so this is
|
|
44
|
+
* safe to call from tests.
|
|
45
|
+
*/
|
|
46
|
+
export declare function clearCache(prefix?: string): Promise<void>;
|
|
47
|
+
/** Resolved cache TTL for command call sites: respects --no-cache + config. */
|
|
48
|
+
export declare function resolveCacheTTL(args: {
|
|
49
|
+
configTTL: number | undefined;
|
|
50
|
+
noCacheFlag: boolean | undefined;
|
|
51
|
+
}): number;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile-aware disk cache with TTL. Stores JSON envelopes at
|
|
3
|
+
* `<profile-dir>/cache/<key>.json`:
|
|
4
|
+
*
|
|
5
|
+
* { v: 1, key, fetchedAt, expiresAt, data }
|
|
6
|
+
*
|
|
7
|
+
* `cached(key, ttlSeconds, fetcher)`:
|
|
8
|
+
* - returns cached `data` when expiresAt > now
|
|
9
|
+
* - else awaits fetcher, writes envelope, returns fresh data
|
|
10
|
+
* - on read error (corrupt JSON, missing dir), silently refetches
|
|
11
|
+
*
|
|
12
|
+
* Bypass:
|
|
13
|
+
* - `bypass: true` option always refetches and rewrites (used by --no-cache)
|
|
14
|
+
* - any error during write is logged via stderr but doesn't fail the call
|
|
15
|
+
*
|
|
16
|
+
* Eviction:
|
|
17
|
+
* - no automatic eviction; old keys stay until manually cleared
|
|
18
|
+
* - `clearCache(prefix?)` for tests + a future `el-linear cache clear` command
|
|
19
|
+
*
|
|
20
|
+
* Path:
|
|
21
|
+
* - profile-aware via resolveActiveProfile() — caches don't bleed between
|
|
22
|
+
* profiles
|
|
23
|
+
*/
|
|
24
|
+
import { randomBytes } from "node:crypto";
|
|
25
|
+
import fs from "node:fs/promises";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { resolveActiveProfile } from "../config/paths.js";
|
|
28
|
+
const CACHE_VERSION = 1;
|
|
29
|
+
const CACHE_FILE_MODE = 0o644;
|
|
30
|
+
const CACHE_DIR_MODE = 0o700;
|
|
31
|
+
/**
|
|
32
|
+
* Resolve `<profile-dir>/cache/`. Profile-aware so caches don't bleed
|
|
33
|
+
* across profiles. The directory of the active profile's `configPath` is
|
|
34
|
+
* the canonical "profile dir" — this matches what `commands/init/shared.ts`
|
|
35
|
+
* uses.
|
|
36
|
+
*/
|
|
37
|
+
function cacheDir() {
|
|
38
|
+
const active = resolveActiveProfile();
|
|
39
|
+
return path.join(path.dirname(active.configPath), "cache");
|
|
40
|
+
}
|
|
41
|
+
function cachePath(key) {
|
|
42
|
+
return path.join(cacheDir(), `${sanitizeKey(key)}.json`);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Cache keys may include filter values like `team:ENG` or `status:active`,
|
|
46
|
+
* which are POSIX-safe but we still strip path separators defensively so a
|
|
47
|
+
* malicious or buggy caller can't write outside the cache directory.
|
|
48
|
+
*/
|
|
49
|
+
function sanitizeKey(key) {
|
|
50
|
+
return key.replace(/[/\\\0]/g, "_");
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Atomic write: write to a sibling tmp file then rename. Mirrors the helper
|
|
54
|
+
* in `commands/init/shared.ts` and `auth/oauth-fs.ts`. Duplicated (12 lines)
|
|
55
|
+
* to keep the dependency graph clean — the wizard depends on cache callers
|
|
56
|
+
* indirectly, so importing wizard internals here would be a cycle hazard.
|
|
57
|
+
*/
|
|
58
|
+
async function atomicWrite(targetPath, data) {
|
|
59
|
+
const tmpPath = `${targetPath}.tmp-${randomBytes(8).toString("hex")}`;
|
|
60
|
+
try {
|
|
61
|
+
await fs.writeFile(tmpPath, data, {
|
|
62
|
+
encoding: "utf8",
|
|
63
|
+
mode: CACHE_FILE_MODE,
|
|
64
|
+
});
|
|
65
|
+
await fs.chmod(tmpPath, CACHE_FILE_MODE);
|
|
66
|
+
await fs.rename(tmpPath, targetPath);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
await fs.unlink(tmpPath).catch(() => { });
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function readEnvelope(key) {
|
|
74
|
+
let raw;
|
|
75
|
+
try {
|
|
76
|
+
raw = await fs.readFile(cachePath(key), "utf8");
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Missing dir, missing file, permission errors → treat as cache miss.
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(raw);
|
|
84
|
+
// Reject envelopes from a future cache version we don't understand.
|
|
85
|
+
if (!parsed ||
|
|
86
|
+
typeof parsed !== "object" ||
|
|
87
|
+
parsed.v !== CACHE_VERSION ||
|
|
88
|
+
typeof parsed.expiresAt !== "number") {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return parsed;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Corrupt JSON → treat as cache miss.
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function writeEnvelope(key, envelope) {
|
|
99
|
+
const dir = cacheDir();
|
|
100
|
+
await fs.mkdir(dir, { recursive: true, mode: CACHE_DIR_MODE });
|
|
101
|
+
await atomicWrite(cachePath(key), JSON.stringify(envelope));
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Read-through cache wrapper.
|
|
105
|
+
*
|
|
106
|
+
* `key` — stable string identifier including any filter params
|
|
107
|
+
* (e.g. `teams-list`, `labels-list-team:ENG`)
|
|
108
|
+
* `ttlSeconds` — lifetime; `0` disables the cache entirely (always
|
|
109
|
+
* refetch, never write).
|
|
110
|
+
* `fetcher` — async function returning the fresh data on a miss.
|
|
111
|
+
* `options.bypass` — force a refetch even when an unexpired envelope
|
|
112
|
+
* exists. Still rewrites on success.
|
|
113
|
+
*/
|
|
114
|
+
export async function cached(key, ttlSeconds, fetcher, options) {
|
|
115
|
+
// TTL = 0 disables caching: never read, never write.
|
|
116
|
+
if (ttlSeconds <= 0) {
|
|
117
|
+
return fetcher();
|
|
118
|
+
}
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
if (!options?.bypass) {
|
|
121
|
+
const envelope = await readEnvelope(key);
|
|
122
|
+
if (envelope && envelope.expiresAt > now) {
|
|
123
|
+
return envelope.data;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const data = await fetcher();
|
|
127
|
+
const envelope = {
|
|
128
|
+
v: CACHE_VERSION,
|
|
129
|
+
key,
|
|
130
|
+
fetchedAt: now,
|
|
131
|
+
expiresAt: now + ttlSeconds * 1000,
|
|
132
|
+
data,
|
|
133
|
+
};
|
|
134
|
+
try {
|
|
135
|
+
await writeEnvelope(key, envelope);
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
// Cache writes are best-effort — log to stderr and return the data
|
|
139
|
+
// anyway so a flaky disk doesn't break the user's command.
|
|
140
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
141
|
+
process.stderr.write(`[disk-cache] write failed for "${key}": ${msg}\n`);
|
|
142
|
+
}
|
|
143
|
+
return data;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Clear cached entries. With no `prefix`, removes the entire cache
|
|
147
|
+
* directory. With a `prefix`, only deletes envelopes whose sanitized key
|
|
148
|
+
* starts with it. Errors (missing dir, permission) are swallowed so this is
|
|
149
|
+
* safe to call from tests.
|
|
150
|
+
*/
|
|
151
|
+
export async function clearCache(prefix) {
|
|
152
|
+
const dir = cacheDir();
|
|
153
|
+
if (!prefix) {
|
|
154
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const sanitized = sanitizeKey(prefix);
|
|
158
|
+
let entries;
|
|
159
|
+
try {
|
|
160
|
+
entries = await fs.readdir(dir);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
await Promise.all(entries
|
|
166
|
+
.filter((name) => name.endsWith(".json") && name.startsWith(sanitized))
|
|
167
|
+
.map((name) => fs.unlink(path.join(dir, name)).catch(() => { })));
|
|
168
|
+
}
|
|
169
|
+
/** Resolved cache TTL for command call sites: respects --no-cache + config. */
|
|
170
|
+
export function resolveCacheTTL(args) {
|
|
171
|
+
if (args.noCacheFlag) {
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
if (args.configTTL === undefined) {
|
|
175
|
+
return 3600;
|
|
176
|
+
}
|
|
177
|
+
return args.configTTL;
|
|
178
|
+
}
|
|
@@ -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
|
+
}
|