@enrichlayer/el-linear 1.6.0 → 1.9.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 +101 -5
- package/claude-skills/linear-operations/SKILL.md +55 -0
- package/dist/auth/oauth-app-config.d.ts +21 -0
- package/dist/auth/oauth-app-config.js +82 -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/init/defaults.d.ts +18 -1
- package/dist/commands/init/defaults.js +83 -2
- package/dist/commands/init/index.js +9 -1
- package/dist/commands/init/oauth.d.ts +10 -4
- package/dist/commands/init/oauth.js +32 -8
- package/dist/commands/init/token.d.ts +0 -8
- package/dist/commands/init/token.js +13 -1
- 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 +187 -0
- package/dist/commands/issues.js +76 -222
- package/dist/commands/labels.js +17 -2
- package/dist/commands/profile/migrate-legacy.js +10 -33
- package/dist/commands/profile.js +1 -10
- package/dist/commands/projects.d.ts +5 -1
- package/dist/commands/projects.js +144 -64
- package/dist/commands/read-shortcut.d.ts +6 -0
- package/dist/commands/read-shortcut.js +6 -1
- package/dist/commands/refs.js +2 -1
- package/dist/commands/teams.js +12 -2
- package/dist/commands/templates.js +127 -1
- package/dist/commands/users.js +2 -1
- package/dist/config/config.d.ts +19 -0
- package/dist/config/config.js +31 -9
- package/dist/config/issue-validation.js +1 -1
- package/dist/config/paths.d.ts +12 -0
- package/dist/config/paths.js +45 -6
- package/dist/config/term-enforcer.js +1 -1
- package/dist/main.js +39 -3
- package/dist/queries/templates.d.ts +3 -0
- package/dist/queries/templates.js +43 -0
- package/dist/utils/auth.js +7 -9
- package/dist/utils/auto-link-references.js +15 -1
- package/dist/utils/disk-cache.d.ts +51 -0
- package/dist/utils/disk-cache.js +179 -0
- package/dist/utils/formatters/summary.d.ts +62 -0
- package/dist/utils/formatters/summary.js +755 -0
- package/dist/utils/graphql-issues-service.d.ts +106 -3
- package/dist/utils/graphql-issues-service.js +51 -37
- 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 +5 -1
- package/dist/utils/output.js +28 -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/package.json +1 -1
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
|
@@ -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
|
|
@@ -30,9 +31,20 @@ export interface ProfileFsOps {
|
|
|
30
31
|
readFileSync: (p: string) => string;
|
|
31
32
|
existsSync: (p: string) => boolean;
|
|
32
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;
|
|
33
41
|
/**
|
|
34
42
|
* Set the active profile for the duration of this process. Pass `null`
|
|
35
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`.
|
|
36
48
|
*/
|
|
37
49
|
export declare function setActiveProfileForSession(name: string | null): void;
|
|
38
50
|
/** Read-only accessor for the per-session override (test seam). */
|
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
|
|
@@ -43,6 +44,15 @@ const DEFAULT_FS_OPS = {
|
|
|
43
44
|
readFileSync: (p) => fs.readFileSync(p, "utf8"),
|
|
44
45
|
existsSync: (p) => fs.existsSync(p),
|
|
45
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
|
+
}
|
|
46
56
|
/**
|
|
47
57
|
* Per-process override for the active profile name. Set by main.ts when
|
|
48
58
|
* `--profile <name>` is passed (highest priority).
|
|
@@ -51,9 +61,25 @@ let sessionProfileOverride = null;
|
|
|
51
61
|
/**
|
|
52
62
|
* Set the active profile for the duration of this process. Pass `null`
|
|
53
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`.
|
|
54
68
|
*/
|
|
55
69
|
export function setActiveProfileForSession(name) {
|
|
56
|
-
|
|
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;
|
|
57
83
|
}
|
|
58
84
|
/** Read-only accessor for the per-session override (test seam). */
|
|
59
85
|
export function getSessionProfileOverride() {
|
|
@@ -65,11 +91,24 @@ export function getSessionProfileOverride() {
|
|
|
65
91
|
* keep working without migration.
|
|
66
92
|
*/
|
|
67
93
|
export function resolveActiveProfile(env = process.env, fsImpl = DEFAULT_FS_OPS) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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);
|
|
73
112
|
}
|
|
74
113
|
return {
|
|
75
114
|
name: null,
|
|
@@ -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,19 +24,21 @@ 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
|
-
.option("--fields <fields>", "filter output to specific fields (comma-separated)")
|
|
40
|
+
.option("--fields <fields>", "filter output to specific fields (comma-separated)")
|
|
41
|
+
.option("--no-cache", "bypass the on-disk cache for `teams list` / `labels list` / `projects list`");
|
|
40
42
|
program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
41
43
|
const rootOpts = actionCommand.optsWithGlobals();
|
|
42
44
|
if (rootOpts.raw) {
|
|
@@ -48,6 +50,40 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
|
48
50
|
if (rootOpts.fields) {
|
|
49
51
|
setFieldsFilter(splitList(rootOpts.fields));
|
|
50
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
|
+
}
|
|
51
87
|
// `--profile <name>` is highest-priority. preAction runs BEFORE the
|
|
52
88
|
// command body, which is BEFORE getApiToken / loadConfig fire — so
|
|
53
89
|
// setting the override here means the rest of the run picks up the
|
|
@@ -1,2 +1,5 @@
|
|
|
1
1
|
export declare const TEMPLATES_LIST_QUERY = "\n query {\n templates {\n id\n name\n type\n description\n templateData\n createdAt\n updatedAt\n team { id key name }\n creator { id name }\n }\n }\n";
|
|
2
2
|
export declare const TEMPLATE_BY_ID_QUERY = "\n query ($id: String!) {\n template(id: $id) {\n id\n name\n type\n description\n templateData\n createdAt\n updatedAt\n team { id key name }\n creator { id name }\n }\n }\n";
|
|
3
|
+
export declare const TEMPLATE_CREATE_MUTATION = "\n mutation ($input: TemplateCreateInput!) {\n templateCreate(input: $input) {\n success\n lastSyncId\n template {\n id\n name\n type\n description\n templateData\n team { id key name }\n createdAt\n updatedAt\n }\n }\n }\n";
|
|
4
|
+
export declare const TEMPLATE_UPDATE_MUTATION = "\n mutation ($id: String!, $input: TemplateUpdateInput!) {\n templateUpdate(id: $id, input: $input) {\n success\n lastSyncId\n template {\n id\n name\n type\n description\n templateData\n team { id key name }\n updatedAt\n }\n }\n }\n";
|
|
5
|
+
export declare const TEMPLATE_DELETE_MUTATION = "\n mutation ($id: String!) {\n templateDelete(id: $id) {\n success\n lastSyncId\n }\n }\n";
|
|
@@ -28,3 +28,46 @@ export const TEMPLATE_BY_ID_QUERY = `
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
`;
|
|
31
|
+
export const TEMPLATE_CREATE_MUTATION = `
|
|
32
|
+
mutation ($input: TemplateCreateInput!) {
|
|
33
|
+
templateCreate(input: $input) {
|
|
34
|
+
success
|
|
35
|
+
lastSyncId
|
|
36
|
+
template {
|
|
37
|
+
id
|
|
38
|
+
name
|
|
39
|
+
type
|
|
40
|
+
description
|
|
41
|
+
templateData
|
|
42
|
+
team { id key name }
|
|
43
|
+
createdAt
|
|
44
|
+
updatedAt
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
`;
|
|
49
|
+
export const TEMPLATE_UPDATE_MUTATION = `
|
|
50
|
+
mutation ($id: String!, $input: TemplateUpdateInput!) {
|
|
51
|
+
templateUpdate(id: $id, input: $input) {
|
|
52
|
+
success
|
|
53
|
+
lastSyncId
|
|
54
|
+
template {
|
|
55
|
+
id
|
|
56
|
+
name
|
|
57
|
+
type
|
|
58
|
+
description
|
|
59
|
+
templateData
|
|
60
|
+
team { id key name }
|
|
61
|
+
updatedAt
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
`;
|
|
66
|
+
export const TEMPLATE_DELETE_MUTATION = `
|
|
67
|
+
mutation ($id: String!) {
|
|
68
|
+
templateDelete(id: $id) {
|
|
69
|
+
success
|
|
70
|
+
lastSyncId
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
`;
|
package/dist/utils/auth.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import { LEGACY_LINCTL_TOKEN_PATH, LEGACY_TOKEN_PATH, resolveActiveProfile,
|
|
2
|
+
import { LEGACY_LINCTL_TOKEN_PATH, LEGACY_TOKEN_PATH, resolveActiveProfile, } from "../config/paths.js";
|
|
3
3
|
import { maybeEmitMigrationHint } from "./migration-hint.js";
|
|
4
4
|
export function getApiToken(options) {
|
|
5
5
|
if (options.apiToken) {
|
|
@@ -9,18 +9,16 @@ export function getApiToken(options) {
|
|
|
9
9
|
return process.env.LINEAR_API_TOKEN;
|
|
10
10
|
}
|
|
11
11
|
// Profile-aware: read from <CONFIG_DIR>/profiles/<name>/token when a
|
|
12
|
-
// profile is active
|
|
13
|
-
// (
|
|
12
|
+
// profile is active. When the user has explicitly selected a profile
|
|
13
|
+
// (--profile / EL_LINEAR_PROFILE / active-profile marker), do NOT
|
|
14
|
+
// fall back to the legacy single-file token — that path silently
|
|
15
|
+
// posts writes to the wrong workspace when a profile token is missing.
|
|
14
16
|
const active = resolveActiveProfile();
|
|
15
17
|
if (fs.existsSync(active.tokenPath)) {
|
|
16
18
|
return fs.readFileSync(active.tokenPath, "utf8").trim();
|
|
17
19
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
// suddenly fail. The active-profile name is informational only when
|
|
21
|
-
// no profile-scoped token exists yet.
|
|
22
|
-
if (active.tokenPath !== TOKEN_PATH && fs.existsSync(TOKEN_PATH)) {
|
|
23
|
-
return fs.readFileSync(TOKEN_PATH, "utf8").trim();
|
|
20
|
+
if (active.name !== null) {
|
|
21
|
+
throw new Error(`No token for active profile \`${active.name}\` (expected at ${active.tokenPath}). Run \`el-linear init token --profile ${active.name}\` (or unset --profile / EL_LINEAR_PROFILE / \`el-linear profile use <name>\`) before retrying. Refusing to fall back to the legacy single-file token to avoid posting writes to the wrong workspace.`);
|
|
24
22
|
}
|
|
25
23
|
// Fallback to the linctl-era token (the CLI was briefly published as
|
|
26
24
|
// `@enrichlayer/linctl` then reverted). Kept for one release.
|
|
@@ -155,7 +155,21 @@ export async function autoLinkReferences(input) {
|
|
|
155
155
|
const failed = [];
|
|
156
156
|
const descriptionRefs = extractIssueReferences(description ?? "", identifier);
|
|
157
157
|
const commentRefs = (comments ?? []).flatMap((body) => extractIssueReferences(body, identifier));
|
|
158
|
-
const
|
|
158
|
+
const merged = mergeCandidates(descriptionRefs, commentRefs);
|
|
159
|
+
// Cap the number of candidates we'll resolve per call. A malicious
|
|
160
|
+
// (or merely careless) issue body containing hundreds of fake
|
|
161
|
+
// identifiers — `AAA-1, AAA-2, …, AAA-999` — would otherwise trigger
|
|
162
|
+
// 999 GraphQL roundtrips before deciding none resolve. Realistic
|
|
163
|
+
// human-authored bodies stay well under this cap.
|
|
164
|
+
const MAX_CANDIDATES_PER_CALL = 50;
|
|
165
|
+
const candidates = merged.slice(0, MAX_CANDIDATES_PER_CALL);
|
|
166
|
+
if (merged.length > MAX_CANDIDATES_PER_CALL) {
|
|
167
|
+
const overflow = merged.length - MAX_CANDIDATES_PER_CALL;
|
|
168
|
+
failed.push({
|
|
169
|
+
identifier: `+${overflow} more`,
|
|
170
|
+
reason: `Too many candidate references (${merged.length}); only the first ${MAX_CANDIDATES_PER_CALL} are processed.`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
159
173
|
if (candidates.length === 0) {
|
|
160
174
|
return { linked, skipped, failed };
|
|
161
175
|
}
|
|
@@ -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,179 @@
|
|
|
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
|
+
import { logger } from "./logger.js";
|
|
29
|
+
const CACHE_VERSION = 1;
|
|
30
|
+
const CACHE_FILE_MODE = 0o644;
|
|
31
|
+
const CACHE_DIR_MODE = 0o700;
|
|
32
|
+
/**
|
|
33
|
+
* Resolve `<profile-dir>/cache/`. Profile-aware so caches don't bleed
|
|
34
|
+
* across profiles. The directory of the active profile's `configPath` is
|
|
35
|
+
* the canonical "profile dir" — this matches what `commands/init/shared.ts`
|
|
36
|
+
* uses.
|
|
37
|
+
*/
|
|
38
|
+
function cacheDir() {
|
|
39
|
+
const active = resolveActiveProfile();
|
|
40
|
+
return path.join(path.dirname(active.configPath), "cache");
|
|
41
|
+
}
|
|
42
|
+
function cachePath(key) {
|
|
43
|
+
return path.join(cacheDir(), `${sanitizeKey(key)}.json`);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Cache keys may include filter values like `team:ENG` or `status:active`,
|
|
47
|
+
* which are POSIX-safe but we still strip path separators defensively so a
|
|
48
|
+
* malicious or buggy caller can't write outside the cache directory.
|
|
49
|
+
*/
|
|
50
|
+
function sanitizeKey(key) {
|
|
51
|
+
return key.replace(/[/\\\0]/g, "_");
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Atomic write: write to a sibling tmp file then rename. Mirrors the helper
|
|
55
|
+
* in `commands/init/shared.ts` and `auth/oauth-fs.ts`. Duplicated (12 lines)
|
|
56
|
+
* to keep the dependency graph clean — the wizard depends on cache callers
|
|
57
|
+
* indirectly, so importing wizard internals here would be a cycle hazard.
|
|
58
|
+
*/
|
|
59
|
+
async function atomicWrite(targetPath, data) {
|
|
60
|
+
const tmpPath = `${targetPath}.tmp-${randomBytes(8).toString("hex")}`;
|
|
61
|
+
try {
|
|
62
|
+
await fs.writeFile(tmpPath, data, {
|
|
63
|
+
encoding: "utf8",
|
|
64
|
+
mode: CACHE_FILE_MODE,
|
|
65
|
+
});
|
|
66
|
+
await fs.chmod(tmpPath, CACHE_FILE_MODE);
|
|
67
|
+
await fs.rename(tmpPath, targetPath);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
await fs.unlink(tmpPath).catch(() => { });
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function readEnvelope(key) {
|
|
75
|
+
let raw;
|
|
76
|
+
try {
|
|
77
|
+
raw = await fs.readFile(cachePath(key), "utf8");
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Missing dir, missing file, permission errors → treat as cache miss.
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(raw);
|
|
85
|
+
// Reject envelopes from a future cache version we don't understand.
|
|
86
|
+
if (!parsed ||
|
|
87
|
+
typeof parsed !== "object" ||
|
|
88
|
+
parsed.v !== CACHE_VERSION ||
|
|
89
|
+
typeof parsed.expiresAt !== "number") {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return parsed;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Corrupt JSON → treat as cache miss.
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async function writeEnvelope(key, envelope) {
|
|
100
|
+
const dir = cacheDir();
|
|
101
|
+
await fs.mkdir(dir, { recursive: true, mode: CACHE_DIR_MODE });
|
|
102
|
+
await atomicWrite(cachePath(key), JSON.stringify(envelope));
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Read-through cache wrapper.
|
|
106
|
+
*
|
|
107
|
+
* `key` — stable string identifier including any filter params
|
|
108
|
+
* (e.g. `teams-list`, `labels-list-team:ENG`)
|
|
109
|
+
* `ttlSeconds` — lifetime; `0` disables the cache entirely (always
|
|
110
|
+
* refetch, never write).
|
|
111
|
+
* `fetcher` — async function returning the fresh data on a miss.
|
|
112
|
+
* `options.bypass` — force a refetch even when an unexpired envelope
|
|
113
|
+
* exists. Still rewrites on success.
|
|
114
|
+
*/
|
|
115
|
+
export async function cached(key, ttlSeconds, fetcher, options) {
|
|
116
|
+
// TTL = 0 disables caching: never read, never write.
|
|
117
|
+
if (ttlSeconds <= 0) {
|
|
118
|
+
return fetcher();
|
|
119
|
+
}
|
|
120
|
+
const now = Date.now();
|
|
121
|
+
if (!options?.bypass) {
|
|
122
|
+
const envelope = await readEnvelope(key);
|
|
123
|
+
if (envelope && envelope.expiresAt > now) {
|
|
124
|
+
return envelope.data;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const data = await fetcher();
|
|
128
|
+
const envelope = {
|
|
129
|
+
v: CACHE_VERSION,
|
|
130
|
+
key,
|
|
131
|
+
fetchedAt: now,
|
|
132
|
+
expiresAt: now + ttlSeconds * 1000,
|
|
133
|
+
data,
|
|
134
|
+
};
|
|
135
|
+
try {
|
|
136
|
+
await writeEnvelope(key, envelope);
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
// Cache writes are best-effort — log to stderr and return the data
|
|
140
|
+
// anyway so a flaky disk doesn't break the user's command.
|
|
141
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
142
|
+
logger.error(`[disk-cache] write failed for "${key}": ${msg}`);
|
|
143
|
+
}
|
|
144
|
+
return data;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Clear cached entries. With no `prefix`, removes the entire cache
|
|
148
|
+
* directory. With a `prefix`, only deletes envelopes whose sanitized key
|
|
149
|
+
* starts with it. Errors (missing dir, permission) are swallowed so this is
|
|
150
|
+
* safe to call from tests.
|
|
151
|
+
*/
|
|
152
|
+
export async function clearCache(prefix) {
|
|
153
|
+
const dir = cacheDir();
|
|
154
|
+
if (!prefix) {
|
|
155
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const sanitized = sanitizeKey(prefix);
|
|
159
|
+
let entries;
|
|
160
|
+
try {
|
|
161
|
+
entries = await fs.readdir(dir);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
await Promise.all(entries
|
|
167
|
+
.filter((name) => name.endsWith(".json") && name.startsWith(sanitized))
|
|
168
|
+
.map((name) => fs.unlink(path.join(dir, name)).catch(() => { })));
|
|
169
|
+
}
|
|
170
|
+
/** Resolved cache TTL for command call sites: respects --no-cache + config. */
|
|
171
|
+
export function resolveCacheTTL(args) {
|
|
172
|
+
if (args.noCacheFlag) {
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
if (args.configTTL === undefined) {
|
|
176
|
+
return 3600;
|
|
177
|
+
}
|
|
178
|
+
return args.configTTL;
|
|
179
|
+
}
|