@enrichlayer/el-linear 1.6.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/init/defaults.d.ts +18 -1
- package/dist/commands/init/defaults.js +83 -2
- package/dist/commands/init/index.js +7 -1
- package/dist/commands/init/oauth.d.ts +4 -4
- package/dist/commands/init/oauth.js +24 -6
- package/dist/commands/issues.js +43 -8
- package/dist/commands/labels.js +15 -2
- package/dist/commands/projects.js +11 -2
- package/dist/commands/teams.js +12 -2
- package/dist/config/config.d.ts +19 -0
- package/dist/config/paths.d.ts +1 -0
- package/dist/config/paths.js +1 -0
- package/dist/main.js +3 -2
- package/dist/utils/disk-cache.d.ts +51 -0
- package/dist/utils/disk-cache.js +178 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -74,13 +74,41 @@ every team ends up writing themselves:
|
|
|
74
74
|
|
|
75
75
|
## Authentication
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
el-linear supports either OAuth or a personal Linear API token. OAuth is
|
|
78
|
+
configured with:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
el-linear init oauth
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
By default, that command walks you through registering your own Linear OAuth
|
|
85
|
+
app. Teams can make the flow a single browser authorization step by writing a
|
|
86
|
+
local, untracked app-defaults file at `~/.config/el-linear/team-oauth.json`
|
|
87
|
+
or pointing `EL_LINEAR_OAUTH_CONFIG` at one:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"linearOAuth": {
|
|
92
|
+
"clientId": "your-linear-oauth-client-id",
|
|
93
|
+
"redirectPort": 8765,
|
|
94
|
+
"scopes": ["read", "write", "issues:create", "comments:create"],
|
|
95
|
+
"passwordManagerPath": "op://vault/item/client_id"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`passwordManagerPath` is optional metadata for humans/scripts; el-linear does
|
|
101
|
+
not execute password-manager commands from it. Do not put a `client_secret` in
|
|
102
|
+
this shared file. The OAuth flow uses PKCE.
|
|
103
|
+
|
|
104
|
+
At runtime, credentials are resolved in this order:
|
|
78
105
|
|
|
79
106
|
1. `--api-token <token>` flag.
|
|
80
107
|
2. `LINEAR_API_TOKEN` environment variable.
|
|
81
|
-
3. **Active profile's**
|
|
82
|
-
4. `~/.config/el-linear/token` file (
|
|
83
|
-
5. `~/.
|
|
108
|
+
3. **Active profile's** OAuth state (`oauth.json`) from `el-linear init oauth`.
|
|
109
|
+
4. **Active profile's** `~/.config/el-linear/profiles/<name>/token` file (see *Profiles* below).
|
|
110
|
+
5. `~/.config/el-linear/token` file (legacy single-profile, recommended for human use when only one workspace is needed).
|
|
111
|
+
6. `~/.linear_api_token` file (legacy, still honored).
|
|
84
112
|
|
|
85
113
|
el-linear never logs the token.
|
|
86
114
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional local OAuth app defaults.
|
|
3
|
+
*
|
|
4
|
+
* This deliberately lives outside the packaged/default config. Teams can
|
|
5
|
+
* materialize it from a password manager, while the OSS CLI keeps requiring
|
|
6
|
+
* users to bring their own OAuth app when no local file exists.
|
|
7
|
+
*/
|
|
8
|
+
import { type OAuthScope } from "./oauth-client.js";
|
|
9
|
+
export declare const TEAM_OAUTH_CONFIG_ENV = "EL_LINEAR_OAUTH_CONFIG";
|
|
10
|
+
export interface TeamOAuthConfig {
|
|
11
|
+
clientId: string;
|
|
12
|
+
redirectPort: number;
|
|
13
|
+
scopes: OAuthScope[];
|
|
14
|
+
/**
|
|
15
|
+
* Optional human-facing pointer such as `op://vault/item/client_id`.
|
|
16
|
+
* The CLI does not execute password-manager commands from this value.
|
|
17
|
+
*/
|
|
18
|
+
passwordManagerPath?: string;
|
|
19
|
+
sourcePath: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function readTeamOAuthConfig(env?: NodeJS.ProcessEnv): Promise<TeamOAuthConfig | null>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional local OAuth app defaults.
|
|
3
|
+
*
|
|
4
|
+
* This deliberately lives outside the packaged/default config. Teams can
|
|
5
|
+
* materialize it from a password manager, while the OSS CLI keeps requiring
|
|
6
|
+
* users to bring their own OAuth app when no local file exists.
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs/promises";
|
|
9
|
+
import { TEAM_OAUTH_CONFIG_PATH } from "../config/paths.js";
|
|
10
|
+
import { DEFAULT_SCOPES, validateScopes, } from "./oauth-client.js";
|
|
11
|
+
export const TEAM_OAUTH_CONFIG_ENV = "EL_LINEAR_OAUTH_CONFIG";
|
|
12
|
+
export async function readTeamOAuthConfig(env = process.env) {
|
|
13
|
+
const envPath = env[TEAM_OAUTH_CONFIG_ENV]?.trim();
|
|
14
|
+
const sourcePath = envPath || TEAM_OAUTH_CONFIG_PATH;
|
|
15
|
+
let raw;
|
|
16
|
+
try {
|
|
17
|
+
raw = await fs.readFile(sourcePath, "utf8");
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
if (err.code === "ENOENT" && !envPath) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
if (err.code === "ENOENT") {
|
|
24
|
+
throw new Error(`${TEAM_OAUTH_CONFIG_ENV} points to ${sourcePath}, but that file does not exist.`);
|
|
25
|
+
}
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(raw);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
34
|
+
throw new Error(`Failed to parse ${sourcePath}: ${message}`);
|
|
35
|
+
}
|
|
36
|
+
const linearOAuth = parsed.linearOAuth;
|
|
37
|
+
if (!linearOAuth || typeof linearOAuth !== "object") {
|
|
38
|
+
throw new Error(`${sourcePath} must contain a linearOAuth object with a clientId.`);
|
|
39
|
+
}
|
|
40
|
+
const clientId = typeof linearOAuth.clientId === "string" ? linearOAuth.clientId.trim() : "";
|
|
41
|
+
if (!clientId) {
|
|
42
|
+
throw new Error(`${sourcePath} linearOAuth.clientId must be a string.`);
|
|
43
|
+
}
|
|
44
|
+
const redirectPort = parseRedirectPort(linearOAuth.redirectPort, sourcePath);
|
|
45
|
+
const scopes = parseScopes(linearOAuth.scopes, sourcePath);
|
|
46
|
+
const passwordManagerPath = parsePasswordManagerPath(linearOAuth.passwordManagerPath, sourcePath);
|
|
47
|
+
return {
|
|
48
|
+
clientId,
|
|
49
|
+
redirectPort,
|
|
50
|
+
scopes,
|
|
51
|
+
passwordManagerPath,
|
|
52
|
+
sourcePath,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function parseRedirectPort(value, sourcePath) {
|
|
56
|
+
if (value === undefined)
|
|
57
|
+
return 8765;
|
|
58
|
+
if (typeof value !== "number" ||
|
|
59
|
+
!Number.isInteger(value) ||
|
|
60
|
+
value < 1024 ||
|
|
61
|
+
value > 65535) {
|
|
62
|
+
throw new Error(`${sourcePath} linearOAuth.redirectPort must be an integer between 1024 and 65535.`);
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
function parseScopes(value, sourcePath) {
|
|
67
|
+
if (value === undefined)
|
|
68
|
+
return [...DEFAULT_SCOPES];
|
|
69
|
+
if (!Array.isArray(value) || !value.every((s) => typeof s === "string")) {
|
|
70
|
+
throw new Error(`${sourcePath} linearOAuth.scopes must be a string array.`);
|
|
71
|
+
}
|
|
72
|
+
return validateScopes(value);
|
|
73
|
+
}
|
|
74
|
+
function parsePasswordManagerPath(value, sourcePath) {
|
|
75
|
+
if (value === undefined)
|
|
76
|
+
return undefined;
|
|
77
|
+
if (typeof value !== "string") {
|
|
78
|
+
throw new Error(`${sourcePath} linearOAuth.passwordManagerPath must be a string when set.`);
|
|
79
|
+
}
|
|
80
|
+
const trimmed = value.trim();
|
|
81
|
+
return trimmed || undefined;
|
|
82
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Step 4 of the wizard: default labels,
|
|
2
|
+
* Step 4 of the wizard: default labels, default assignee, default priority,
|
|
3
|
+
* status defaults, term enforcement, cache TTL.
|
|
3
4
|
*
|
|
4
5
|
* All optional. Each subsection asks "change?" with default=N so re-running
|
|
5
6
|
* with no input is a no-op.
|
|
@@ -7,6 +8,19 @@
|
|
|
7
8
|
import { type WizardConfig } from "./shared.js";
|
|
8
9
|
export interface DefaultsStepResult {
|
|
9
10
|
defaultLabels: string[] | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Default assignee identifier (alias / display name / email / UUID — same
|
|
13
|
+
* shapes resolveAssignee accepts) for `issues create`. The wizard does NOT
|
|
14
|
+
* validate this against Linear (no API call) — the runtime resolver handles
|
|
15
|
+
* validation at issue-create time, keeping the wizard offline.
|
|
16
|
+
*/
|
|
17
|
+
defaultAssignee: string | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Default priority keyword: `none|urgent|high|medium|normal|low`. Stored
|
|
20
|
+
* as-is; the runtime path runs it through validatePriority() to get the
|
|
21
|
+
* Linear priority number.
|
|
22
|
+
*/
|
|
23
|
+
defaultPriority: string | undefined;
|
|
10
24
|
/**
|
|
11
25
|
* Status defaults. Both fields are optional so the wizard preserves
|
|
12
26
|
* partial existing configs (`{ noProject: "Backlog" }` only) byte-for-byte
|
|
@@ -21,5 +35,8 @@ export interface DefaultsStepResult {
|
|
|
21
35
|
canonical: string;
|
|
22
36
|
reject: string[];
|
|
23
37
|
}> | undefined;
|
|
38
|
+
/** TTL (seconds) for `teams list` / `labels list` / `projects list` disk
|
|
39
|
+
* cache. `0` disables. */
|
|
40
|
+
cacheTTLSeconds: number | undefined;
|
|
24
41
|
}
|
|
25
42
|
export declare function runDefaultsStep(existing: WizardConfig): Promise<DefaultsStepResult>;
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Step 4 of the wizard: default labels,
|
|
2
|
+
* Step 4 of the wizard: default labels, default assignee, default priority,
|
|
3
|
+
* status defaults, term enforcement, cache TTL.
|
|
3
4
|
*
|
|
4
5
|
* All optional. Each subsection asks "change?" with default=N so re-running
|
|
5
6
|
* with no input is a no-op.
|
|
6
7
|
*/
|
|
7
|
-
import { confirm, input } from "@inquirer/prompts";
|
|
8
|
+
import { confirm, input, select } from "@inquirer/prompts";
|
|
8
9
|
import { parseCsvList } from "./shared.js";
|
|
9
10
|
const STATUS_FALLBACK = { noProject: "Triage", withAssigneeAndProject: "Todo" };
|
|
11
|
+
const CACHE_TTL_FALLBACK = 3600;
|
|
12
|
+
const PRIORITY_CHOICES = [
|
|
13
|
+
{ name: "none", value: "none" },
|
|
14
|
+
{ name: "urgent", value: "urgent" },
|
|
15
|
+
{ name: "high", value: "high" },
|
|
16
|
+
{ name: "medium", value: "medium" },
|
|
17
|
+
{ name: "normal", value: "normal" },
|
|
18
|
+
{ name: "low", value: "low" },
|
|
19
|
+
];
|
|
10
20
|
export async function runDefaultsStep(existing) {
|
|
11
21
|
// Idempotency rule: when the user skips a sub-section, return the existing
|
|
12
22
|
// value byte-for-byte. We deliberately do NOT spread or backfill optional
|
|
@@ -14,8 +24,11 @@ export async function runDefaultsStep(existing) {
|
|
|
14
24
|
// with no input produces a byte-identical config.
|
|
15
25
|
const result = {
|
|
16
26
|
defaultLabels: existing.defaultLabels,
|
|
27
|
+
defaultAssignee: existing.defaultAssignee,
|
|
28
|
+
defaultPriority: existing.defaultPriority,
|
|
17
29
|
statusDefaults: existing.statusDefaults,
|
|
18
30
|
terms: existing.terms,
|
|
31
|
+
cacheTTLSeconds: existing.cacheTTLSeconds,
|
|
19
32
|
};
|
|
20
33
|
// ── Default labels ────────────────────────────────────────────────
|
|
21
34
|
const currentLabels = existing.defaultLabels ?? [];
|
|
@@ -32,6 +45,45 @@ export async function runDefaultsStep(existing) {
|
|
|
32
45
|
const parsed = parseCsvList(raw);
|
|
33
46
|
result.defaultLabels = parsed.length > 0 ? parsed : undefined;
|
|
34
47
|
}
|
|
48
|
+
// ── Default assignee ──────────────────────────────────────────────
|
|
49
|
+
console.log(` Current default assignee: ${existing.defaultAssignee ?? "(none)"}`);
|
|
50
|
+
const editAssignee = await confirm({
|
|
51
|
+
message: "Change default assignee for new issues?",
|
|
52
|
+
default: false,
|
|
53
|
+
});
|
|
54
|
+
if (editAssignee) {
|
|
55
|
+
// We deliberately don't validate against Linear here — the wizard runs
|
|
56
|
+
// offline-friendly. The runtime resolver (resolveAssignee) catches
|
|
57
|
+
// typos at issue-create time. "none" is the explicit-clear sentinel
|
|
58
|
+
// so users have an unambiguous way to wipe the field.
|
|
59
|
+
const raw = (await input({
|
|
60
|
+
message: "Default assignee (alias, name, email, or 'none' to clear):",
|
|
61
|
+
default: existing.defaultAssignee ?? "",
|
|
62
|
+
})).trim();
|
|
63
|
+
if (!raw || raw.toLowerCase() === "none") {
|
|
64
|
+
result.defaultAssignee = undefined;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
result.defaultAssignee = raw;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// ── Default priority ──────────────────────────────────────────────
|
|
71
|
+
console.log(` Current default priority: ${existing.defaultPriority ?? "(none)"}`);
|
|
72
|
+
const editPriority = await confirm({
|
|
73
|
+
message: "Change default priority for new issues?",
|
|
74
|
+
default: false,
|
|
75
|
+
});
|
|
76
|
+
if (editPriority) {
|
|
77
|
+
// `select` always picks one option — so picking "none" stores the
|
|
78
|
+
// keyword string "none" (Linear's "No priority"), distinct from
|
|
79
|
+
// `undefined` which means "no default at all".
|
|
80
|
+
const choice = await select({
|
|
81
|
+
message: "Default priority:",
|
|
82
|
+
choices: PRIORITY_CHOICES,
|
|
83
|
+
default: existing.defaultPriority ?? "none",
|
|
84
|
+
});
|
|
85
|
+
result.defaultPriority = choice;
|
|
86
|
+
}
|
|
35
87
|
// ── Status defaults ────────────────────────────────────────────────
|
|
36
88
|
const cur = existing.statusDefaults;
|
|
37
89
|
console.log(` Current status defaults: noProject=${cur?.noProject ?? STATUS_FALLBACK.noProject}, ` +
|
|
@@ -54,6 +106,35 @@ export async function runDefaultsStep(existing) {
|
|
|
54
106
|
withAssigneeAndProject: withAP.trim(),
|
|
55
107
|
};
|
|
56
108
|
}
|
|
109
|
+
// ── Cache TTL ─────────────────────────────────────────────────────
|
|
110
|
+
const currentTTL = existing.cacheTTLSeconds ?? CACHE_TTL_FALLBACK;
|
|
111
|
+
console.log(` Current cache TTL: ${currentTTL}s`);
|
|
112
|
+
const editTTL = await confirm({
|
|
113
|
+
message: "Change cache TTL?",
|
|
114
|
+
default: false,
|
|
115
|
+
});
|
|
116
|
+
if (editTTL) {
|
|
117
|
+
const raw = await input({
|
|
118
|
+
message: "Cache TTL in seconds (0 to disable):",
|
|
119
|
+
default: String(currentTTL),
|
|
120
|
+
// `validate` runs per submit; we reject anything that isn't a
|
|
121
|
+
// non-negative integer literal. Number("") is `0` (truthy by
|
|
122
|
+
// the integer test) so we explicitly require non-empty input —
|
|
123
|
+
// otherwise an empty submission would silently store 0.
|
|
124
|
+
validate: (value) => {
|
|
125
|
+
const trimmed = value.trim();
|
|
126
|
+
if (trimmed === "") {
|
|
127
|
+
return "Enter a non-negative integer (e.g. 3600 for 1 hour, 0 to disable).";
|
|
128
|
+
}
|
|
129
|
+
const n = Number(trimmed);
|
|
130
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
|
|
131
|
+
return "Enter a non-negative integer (e.g. 3600 for 1 hour, 0 to disable).";
|
|
132
|
+
}
|
|
133
|
+
return true;
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
result.cacheTTLSeconds = Number(raw);
|
|
137
|
+
}
|
|
57
138
|
// ── Term enforcement ───────────────────────────────────────────────
|
|
58
139
|
const currentTerms = existing.terms ?? [];
|
|
59
140
|
console.log(` Current term-enforcement rules: ${currentTerms.length}`);
|
|
@@ -127,15 +127,18 @@ export function setupInitCommands(program) {
|
|
|
127
127
|
}));
|
|
128
128
|
init
|
|
129
129
|
.command("defaults")
|
|
130
|
-
.description("Default labels, status defaults, term enforcement
|
|
130
|
+
.description("Default labels, default assignee, default priority, status defaults, cache TTL, term enforcement")
|
|
131
131
|
.action(withCleanExit(async () => {
|
|
132
132
|
const existing = await readConfig();
|
|
133
133
|
printStep("defaults", "Defaults");
|
|
134
134
|
const result = await runDefaultsStep(existing);
|
|
135
135
|
const merged = assignDefined(existing, {
|
|
136
136
|
defaultLabels: result.defaultLabels,
|
|
137
|
+
defaultAssignee: result.defaultAssignee,
|
|
138
|
+
defaultPriority: result.defaultPriority,
|
|
137
139
|
statusDefaults: result.statusDefaults,
|
|
138
140
|
terms: result.terms,
|
|
141
|
+
cacheTTLSeconds: result.cacheTTLSeconds,
|
|
139
142
|
});
|
|
140
143
|
await writeConfig(merged);
|
|
141
144
|
console.log(" ✓ Defaults saved.");
|
|
@@ -175,8 +178,11 @@ async function runFullWizardImpl(options) {
|
|
|
175
178
|
// defaults step: result is `existing.X` itself when the user skipped
|
|
176
179
|
// the edit branch, so direct assignment is safe.
|
|
177
180
|
defaultLabels: defaults.defaultLabels,
|
|
181
|
+
defaultAssignee: defaults.defaultAssignee,
|
|
182
|
+
defaultPriority: defaults.defaultPriority,
|
|
178
183
|
statusDefaults: defaults.statusDefaults,
|
|
179
184
|
terms: defaults.terms,
|
|
185
|
+
cacheTTLSeconds: defaults.cacheTTLSeconds,
|
|
180
186
|
// workspace step: `ws.defaultTeam` may be the existing value (user
|
|
181
187
|
// skipped) or a new pick.
|
|
182
188
|
defaultTeam: ws.defaultTeam,
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* Wizard step for OAuth 2.0 (PKCE flow) authorization.
|
|
3
3
|
*
|
|
4
4
|
* Flow:
|
|
5
|
-
* 1.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* 1. Read optional local/team OAuth app defaults, if present.
|
|
6
|
+
* 2. Otherwise present a "what is this?" intro pointing the user at
|
|
7
|
+
* Linear's OAuth app registration page, then prompt for `client_id`,
|
|
8
|
+
* optional `client_secret`, port, scopes.
|
|
9
9
|
* 3. Generate PKCE verifier + state, build the authorize URL.
|
|
10
10
|
* 4. Try to open the system browser; fall back to printing the URL.
|
|
11
11
|
* 5. Spin a localhost listener (or fall back to pasted-code prompt) to
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* Wizard step for OAuth 2.0 (PKCE flow) authorization.
|
|
3
3
|
*
|
|
4
4
|
* Flow:
|
|
5
|
-
* 1.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* 1. Read optional local/team OAuth app defaults, if present.
|
|
6
|
+
* 2. Otherwise present a "what is this?" intro pointing the user at
|
|
7
|
+
* Linear's OAuth app registration page, then prompt for `client_id`,
|
|
8
|
+
* optional `client_secret`, port, scopes.
|
|
9
9
|
* 3. Generate PKCE verifier + state, build the authorize URL.
|
|
10
10
|
* 4. Try to open the system browser; fall back to printing the URL.
|
|
11
11
|
* 5. Spin a localhost listener (or fall back to pasted-code prompt) to
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { spawn } from "node:child_process";
|
|
21
21
|
import { checkbox, input, password, select } from "@inquirer/prompts";
|
|
22
|
+
import { readTeamOAuthConfig } from "../../auth/oauth-app-config.js";
|
|
22
23
|
import { DEFAULT_CALLBACK_PATH, runLocalhostCallback, } from "../../auth/oauth-callback.js";
|
|
23
24
|
import { ALL_SCOPES, buildAuthorizeUrl, DEFAULT_SCOPES, generatePkce, generateState, SCOPE_DESCRIPTIONS, validateScopes, } from "../../auth/oauth-client.js";
|
|
24
25
|
import { promptForPastedCode } from "../../auth/oauth-headless.js";
|
|
@@ -165,6 +166,22 @@ async function promptRegistration(defaults) {
|
|
|
165
166
|
scopes: validateScopes(scopes),
|
|
166
167
|
};
|
|
167
168
|
}
|
|
169
|
+
async function resolveRegistration(defaults) {
|
|
170
|
+
const teamConfig = await readTeamOAuthConfig();
|
|
171
|
+
if (!teamConfig) {
|
|
172
|
+
return promptRegistration({ port: defaults.manualPort });
|
|
173
|
+
}
|
|
174
|
+
const port = defaults.requestedPort ?? teamConfig.redirectPort;
|
|
175
|
+
logLine("");
|
|
176
|
+
logLine(TS(`Using Linear OAuth app defaults from ${teamConfig.sourcePath}.`));
|
|
177
|
+
logLine(TS(`Callback URL: http://localhost:${port}${DEFAULT_CALLBACK_PATH}`));
|
|
178
|
+
logLine("");
|
|
179
|
+
return {
|
|
180
|
+
clientId: teamConfig.clientId,
|
|
181
|
+
port,
|
|
182
|
+
scopes: teamConfig.scopes,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
168
185
|
/**
|
|
169
186
|
* Default viewer-validation routine. Calls `viewer { ... }` with the new
|
|
170
187
|
* bearer token to confirm Linear accepted it. Reused for both the wizard
|
|
@@ -213,8 +230,9 @@ export async function runOAuthStep(options = {}) {
|
|
|
213
230
|
}
|
|
214
231
|
// Both `reauth` and `revoked` fall through to the re-auth flow.
|
|
215
232
|
}
|
|
216
|
-
const reg = await
|
|
217
|
-
|
|
233
|
+
const reg = await resolveRegistration({
|
|
234
|
+
manualPort: options.port ?? extractPortFromRedirect(existing) ?? DEFAULT_PORT,
|
|
235
|
+
requestedPort: options.port,
|
|
218
236
|
});
|
|
219
237
|
const redirectUri = `http://localhost:${reg.port}${DEFAULT_CALLBACK_PATH}`;
|
|
220
238
|
const pkce = generatePkce();
|
package/dist/commands/issues.js
CHANGED
|
@@ -263,12 +263,20 @@ function buildUpdateArgs(issueId, options, assigneeId) {
|
|
|
263
263
|
else if (options.labels) {
|
|
264
264
|
labelIds = splitList(options.labels);
|
|
265
265
|
}
|
|
266
|
+
// On update, fall back to config.defaultPriority only when --priority
|
|
267
|
+
// wasn't passed. This keeps every update setting a priority for users who
|
|
268
|
+
// want a workspace-wide baseline (e.g. all unassigned triage tickets bump
|
|
269
|
+
// to "medium"). To leave priority untouched, omit defaultPriority from
|
|
270
|
+
// config — the field is opt-in.
|
|
271
|
+
const priorityInput = typeof options.priority === "string"
|
|
272
|
+
? options.priority
|
|
273
|
+
: loadConfig().defaultPriority;
|
|
266
274
|
return {
|
|
267
275
|
id: issueId,
|
|
268
276
|
title: options.title,
|
|
269
277
|
description: options.description,
|
|
270
278
|
statusId: options.status,
|
|
271
|
-
priority:
|
|
279
|
+
priority: priorityInput ? validatePriority(priorityInput) : undefined,
|
|
272
280
|
assigneeId,
|
|
273
281
|
projectId: options.project,
|
|
274
282
|
labelIds,
|
|
@@ -392,6 +400,21 @@ async function handleSearchIssues(query, options, command) {
|
|
|
392
400
|
async function resolveCreateInputs(title, options, rootOpts) {
|
|
393
401
|
const config = loadConfig();
|
|
394
402
|
enforceTerms([title, options.description], { strict: options.strict });
|
|
403
|
+
// Effective assignee: explicit --assignee wins; --no-assignee (commander
|
|
404
|
+
// parses as `assignee === false`) skips both flag and config; otherwise
|
|
405
|
+
// fall back to config.defaultAssignee. Computed BEFORE validation so the
|
|
406
|
+
// "assignee required" rule sees the resolved value, not undefined.
|
|
407
|
+
const noAssignee = options.assignee === false;
|
|
408
|
+
const explicitAssignee = typeof options.assignee === "string" ? options.assignee : undefined;
|
|
409
|
+
const effectiveAssignee = noAssignee
|
|
410
|
+
? undefined
|
|
411
|
+
: (explicitAssignee ?? config.defaultAssignee);
|
|
412
|
+
// Effective priority: explicit --priority wins; otherwise fall back to
|
|
413
|
+
// config.defaultPriority. Both go through validatePriority so a bad config
|
|
414
|
+
// value fails fast with a useful error.
|
|
415
|
+
const effectivePriorityInput = typeof options.priority === "string"
|
|
416
|
+
? options.priority
|
|
417
|
+
: config.defaultPriority;
|
|
395
418
|
// --- Validation (labels, description, assignee, project, title) ---
|
|
396
419
|
// Controlled by config.validation.enabled (default: true).
|
|
397
420
|
// Bypassed by --skip-validation flag.
|
|
@@ -402,7 +425,7 @@ async function resolveCreateInputs(title, options, rootOpts) {
|
|
|
402
425
|
labels: rawLabels,
|
|
403
426
|
description: description || undefined,
|
|
404
427
|
title,
|
|
405
|
-
assignee:
|
|
428
|
+
assignee: effectiveAssignee,
|
|
406
429
|
project: options.project,
|
|
407
430
|
});
|
|
408
431
|
// Apply normalized labels back so resolution uses the canonical names
|
|
@@ -411,13 +434,13 @@ async function resolveCreateInputs(title, options, rootOpts) {
|
|
|
411
434
|
}
|
|
412
435
|
enforceValidation(validationResult);
|
|
413
436
|
}
|
|
414
|
-
if (!
|
|
437
|
+
if (!effectivePriorityInput) {
|
|
415
438
|
outputWarning("Creating issue without --priority. Consider specifying it for better triage.", "missing_fields");
|
|
416
439
|
}
|
|
417
440
|
const teamInput = options.team || config.defaultTeam;
|
|
418
441
|
const teamId = resolveTeam(teamInput);
|
|
419
|
-
const assigneeId =
|
|
420
|
-
? await resolveAssignee(
|
|
442
|
+
const assigneeId = effectiveAssignee
|
|
443
|
+
? await resolveAssignee(effectiveAssignee, rootOpts)
|
|
421
444
|
: undefined;
|
|
422
445
|
let labelIds = [];
|
|
423
446
|
if (options.labels) {
|
|
@@ -438,7 +461,18 @@ async function resolveCreateInputs(title, options, rootOpts) {
|
|
|
438
461
|
if (options.subscriber) {
|
|
439
462
|
subscriberIds = splitList(options.subscriber).map((s) => resolveMember(s));
|
|
440
463
|
}
|
|
441
|
-
|
|
464
|
+
const priority = effectivePriorityInput
|
|
465
|
+
? validatePriority(effectivePriorityInput)
|
|
466
|
+
: undefined;
|
|
467
|
+
return {
|
|
468
|
+
teamInput,
|
|
469
|
+
teamId,
|
|
470
|
+
assigneeId,
|
|
471
|
+
labelIds,
|
|
472
|
+
status,
|
|
473
|
+
subscriberIds,
|
|
474
|
+
priority,
|
|
475
|
+
};
|
|
442
476
|
}
|
|
443
477
|
async function uploadAttachmentsIfNeeded(options, rootOpts) {
|
|
444
478
|
if (!options.attachment) {
|
|
@@ -472,7 +506,7 @@ function buildDescriptionWithAttachments(baseDescription, uploadResults) {
|
|
|
472
506
|
}
|
|
473
507
|
async function handleCreateIssue(title, options, command) {
|
|
474
508
|
const rootOpts = getRootOpts(command);
|
|
475
|
-
const { teamInput, teamId, assigneeId, labelIds, status, subscriberIds } = await resolveCreateInputs(title, options, rootOpts);
|
|
509
|
+
const { teamInput, teamId, assigneeId, labelIds, status, subscriberIds, priority, } = await resolveCreateInputs(title, options, rootOpts);
|
|
476
510
|
const uploadResults = await uploadAttachmentsIfNeeded(options, rootOpts);
|
|
477
511
|
const descriptionWithAttachments = buildDescriptionWithAttachments(resolveDescription(options) || "", uploadResults);
|
|
478
512
|
// Append messageFooter (config or --footer flag) so auto-link picks up any
|
|
@@ -497,7 +531,7 @@ async function handleCreateIssue(title, options, command) {
|
|
|
497
531
|
teamInput,
|
|
498
532
|
description: prepared.description,
|
|
499
533
|
assigneeId,
|
|
500
|
-
priority
|
|
534
|
+
priority,
|
|
501
535
|
projectId: options.project,
|
|
502
536
|
statusId: status,
|
|
503
537
|
labelIds: labelIds.length > 0 ? labelIds : undefined,
|
|
@@ -1075,6 +1109,7 @@ export function setupIssuesCommands(program) {
|
|
|
1075
1109
|
.option("--description-file <path>", "read description from file (use - for stdin)")
|
|
1076
1110
|
.option("--template <name>", "use a named description template from config.descriptionTemplates")
|
|
1077
1111
|
.option("-a, --assignee <assignee>", "assign to user (name, alias, or UUID)")
|
|
1112
|
+
.option("--no-assignee", "create unassigned even when config.defaultAssignee is set")
|
|
1078
1113
|
.option("-p, --priority <priority>", "priority: name (none/urgent/high/medium/normal/low) or number (0-4)")
|
|
1079
1114
|
.option("--project <project>", "add to project (name or ID)")
|
|
1080
1115
|
.option("--team <team>", "team key or name (default: from config)")
|
package/dist/commands/labels.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { loadConfig } from "../config/config.js";
|
|
1
2
|
import { resolveTeam } from "../config/resolver.js";
|
|
2
3
|
import { CREATE_LABEL_MUTATION, FIND_PARENT_LABEL_QUERY, RESTORE_LABEL_MUTATION, RETIRE_LABEL_MUTATION, } from "../queries/labels.js";
|
|
4
|
+
import { cached, resolveCacheTTL } from "../utils/disk-cache.js";
|
|
3
5
|
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
4
6
|
import { createLinearService } from "../utils/linear-service.js";
|
|
5
7
|
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
@@ -75,8 +77,19 @@ export function setupLabelsCommands(program) {
|
|
|
75
77
|
.action(handleAsyncCommand(async (options, command) => {
|
|
76
78
|
const rootOpts = getRootOpts(command);
|
|
77
79
|
const teamFilter = options.team ? resolveTeam(options.team) : undefined;
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
+
const limit = Number.parseInt(options.limit, 10);
|
|
81
|
+
const ttl = resolveCacheTTL({
|
|
82
|
+
configTTL: loadConfig().cacheTTLSeconds,
|
|
83
|
+
noCacheFlag: rootOpts.cache === false,
|
|
84
|
+
});
|
|
85
|
+
// Cache key includes the team filter so list-with-team and list-
|
|
86
|
+
// without-team don't collide. Same `limit` participates because
|
|
87
|
+
// a smaller list isn't a valid cached answer for a larger ask.
|
|
88
|
+
const cacheKey = `labels-list-team:${teamFilter ?? "_all"}-limit:${limit}`;
|
|
89
|
+
const result = await cached(cacheKey, ttl, async () => {
|
|
90
|
+
const service = await createLinearService(rootOpts);
|
|
91
|
+
return service.getLabels(teamFilter, limit);
|
|
92
|
+
});
|
|
80
93
|
outputSuccess({
|
|
81
94
|
data: result.labels,
|
|
82
95
|
meta: { count: result.labels.length },
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { loadConfig } from "../config/config.js";
|
|
1
2
|
import { resolveTeam } from "../config/resolver.js";
|
|
2
3
|
import { CREATE_PROJECT_MUTATION, GET_PROJECT_QUERY, GET_PROJECT_TEAM_ISSUES_QUERY, PROJECT_BY_ID_QUERY, SEARCH_PROJECTS_BY_NAME_QUERY, UPDATE_PROJECT_MUTATION, } from "../queries/projects.js";
|
|
4
|
+
import { cached, resolveCacheTTL } from "../utils/disk-cache.js";
|
|
3
5
|
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
4
6
|
import { createLinearService } from "../utils/linear-service.js";
|
|
5
7
|
import { logger } from "../utils/logger.js";
|
|
@@ -305,8 +307,15 @@ export function setupProjectsCommands(program) {
|
|
|
305
307
|
.option("--fields <fields>", "columns for table/csv (comma-separated: name,state,progress,teams,lead,targetDate)")
|
|
306
308
|
.action(handleAsyncCommand(async (options, command) => {
|
|
307
309
|
const rootOpts = getRootOpts(command);
|
|
308
|
-
const
|
|
309
|
-
const
|
|
310
|
+
const limit = Number.parseInt(options.limit, 10);
|
|
311
|
+
const ttl = resolveCacheTTL({
|
|
312
|
+
configTTL: loadConfig().cacheTTLSeconds,
|
|
313
|
+
noCacheFlag: rootOpts.cache === false,
|
|
314
|
+
});
|
|
315
|
+
const result = await cached(`projects-list-limit:${limit}`, ttl, async () => {
|
|
316
|
+
const service = await createLinearService(rootOpts);
|
|
317
|
+
return service.getProjects(limit);
|
|
318
|
+
});
|
|
310
319
|
const format = options.format;
|
|
311
320
|
if (format === "table" ||
|
|
312
321
|
format === "md" ||
|
package/dist/commands/teams.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
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";
|
|
3
5
|
import { getRootOpts } from "../utils/root-opts.js";
|
|
@@ -13,8 +15,16 @@ export function setupTeamsCommands(program) {
|
|
|
13
15
|
.option("-l, --limit <number>", "limit results", "100")
|
|
14
16
|
.action(handleAsyncCommand(async (options, command) => {
|
|
15
17
|
const rootOpts = getRootOpts(command);
|
|
16
|
-
const
|
|
17
|
-
const
|
|
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
|
+
});
|
|
18
28
|
outputSuccess({ data: result, meta: { count: result.length } });
|
|
19
29
|
}));
|
|
20
30
|
}
|
package/dist/config/config.d.ts
CHANGED
|
@@ -57,6 +57,25 @@ export interface ElLinearConfig {
|
|
|
57
57
|
* }
|
|
58
58
|
*/
|
|
59
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;
|
|
60
79
|
}
|
|
61
80
|
/** Test seam — resets the cache between test cases. */
|
|
62
81
|
export declare function _resetConfigCacheForTests(): void;
|
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/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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "A pragmatic CLI for Linear.app — deterministic team/label/member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch.",
|
|
5
5
|
"main": "dist/main.js",
|
|
6
6
|
"types": "dist/main.d.ts",
|