linear-sdk-cli 0.1.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/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/plugin.json +9 -0
- package/CHANGELOG.md +807 -0
- package/COVERAGE.md +495 -0
- package/LICENSE +21 -0
- package/MIGRATING.md +220 -0
- package/README.md +524 -0
- package/package.json +87 -0
- package/skills/linear-sdk-cli/SKILL.md +534 -0
- package/skills/linear-sdk-cli/references/api.md +27 -0
- package/skills/linear-sdk-cli/references/attachment.md +82 -0
- package/skills/linear-sdk-cli/references/auth.md +174 -0
- package/skills/linear-sdk-cli/references/commands.md +37 -0
- package/skills/linear-sdk-cli/references/comment.md +154 -0
- package/skills/linear-sdk-cli/references/completion.md +17 -0
- package/skills/linear-sdk-cli/references/config.md +99 -0
- package/skills/linear-sdk-cli/references/cycle.md +131 -0
- package/skills/linear-sdk-cli/references/document.md +180 -0
- package/skills/linear-sdk-cli/references/favorite.md +78 -0
- package/skills/linear-sdk-cli/references/initiative-update.md +67 -0
- package/skills/linear-sdk-cli/references/initiative.md +256 -0
- package/skills/linear-sdk-cli/references/issue.md +928 -0
- package/skills/linear-sdk-cli/references/label.md +112 -0
- package/skills/linear-sdk-cli/references/milestone.md +139 -0
- package/skills/linear-sdk-cli/references/notification.md +123 -0
- package/skills/linear-sdk-cli/references/open.md +29 -0
- package/skills/linear-sdk-cli/references/organization.md +99 -0
- package/skills/linear-sdk-cli/references/project-update.md +67 -0
- package/skills/linear-sdk-cli/references/project.md +234 -0
- package/skills/linear-sdk-cli/references/roadmap.md +150 -0
- package/skills/linear-sdk-cli/references/schema.md +21 -0
- package/skills/linear-sdk-cli/references/state.md +61 -0
- package/skills/linear-sdk-cli/references/team.md +227 -0
- package/skills/linear-sdk-cli/references/user.md +103 -0
- package/skills/linear-sdk-cli/references/webhook.md +151 -0
- package/skills/linear-sdk-cli/references/whoami.md +26 -0
- package/skills/linear-sdk-cli/scripts/generate-docs.ts +339 -0
- package/src/bin/linear.ts +97 -0
- package/src/cli.ts +232 -0
- package/src/client.ts +172 -0
- package/src/commands/agent-session.ts +121 -0
- package/src/commands/api.ts +317 -0
- package/src/commands/attach.ts +90 -0
- package/src/commands/attachment.ts +74 -0
- package/src/commands/comment.ts +225 -0
- package/src/commands/completion.ts +69 -0
- package/src/commands/cycle.ts +125 -0
- package/src/commands/discover.ts +170 -0
- package/src/commands/document.ts +211 -0
- package/src/commands/favorite.ts +72 -0
- package/src/commands/initiative-update.ts +47 -0
- package/src/commands/initiative.ts +261 -0
- package/src/commands/issue.ts +1263 -0
- package/src/commands/label.ts +119 -0
- package/src/commands/meta.ts +534 -0
- package/src/commands/milestone.ts +163 -0
- package/src/commands/notification.ts +110 -0
- package/src/commands/open.ts +84 -0
- package/src/commands/organization.ts +79 -0
- package/src/commands/project-update.ts +47 -0
- package/src/commands/project.ts +339 -0
- package/src/commands/roadmap.ts +131 -0
- package/src/commands/state.ts +58 -0
- package/src/commands/team.ts +243 -0
- package/src/commands/user.ts +107 -0
- package/src/commands/webhook.ts +156 -0
- package/src/config.ts +1236 -0
- package/src/context.ts +117 -0
- package/src/git.ts +174 -0
- package/src/index.ts +10 -0
- package/src/lib/action.ts +26 -0
- package/src/lib/body.ts +172 -0
- package/src/lib/errors.ts +195 -0
- package/src/lib/introspect.ts +150 -0
- package/src/lib/keyring.ts +270 -0
- package/src/lib/mutation.ts +53 -0
- package/src/lib/open.ts +32 -0
- package/src/lib/options.ts +640 -0
- package/src/lib/output-shapes.ts +562 -0
- package/src/lib/pagination.ts +145 -0
- package/src/lib/prompt.ts +91 -0
- package/src/lib/resolve.ts +596 -0
- package/src/lib/shape.ts +273 -0
- package/src/lib/status-update.ts +91 -0
- package/src/lib/upload.ts +329 -0
- package/src/output/color.ts +52 -0
- package/src/output/format.ts +238 -0
- package/src/output/markdown.ts +133 -0
- package/src/output/pager.ts +131 -0
- package/src/output/sanitize.ts +76 -0
- package/src/output/table.ts +198 -0
- package/src/services/agent-session.ts +289 -0
- package/src/services/attachment.ts +198 -0
- package/src/services/comment.ts +262 -0
- package/src/services/cycle.ts +224 -0
- package/src/services/document.ts +357 -0
- package/src/services/favorite.ts +147 -0
- package/src/services/initiative-update.ts +59 -0
- package/src/services/initiative.ts +449 -0
- package/src/services/issue.ts +1153 -0
- package/src/services/label.ts +193 -0
- package/src/services/milestone.ts +262 -0
- package/src/services/notification.ts +190 -0
- package/src/services/organization.ts +144 -0
- package/src/services/project-update.ts +36 -0
- package/src/services/project.ts +494 -0
- package/src/services/roadmap.ts +177 -0
- package/src/services/state.ts +127 -0
- package/src/services/team.ts +386 -0
- package/src/services/user.ts +137 -0
- package/src/services/webhook.ts +203 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,1236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration resolution.
|
|
3
|
+
*
|
|
4
|
+
* Two tiers with different trust boundaries:
|
|
5
|
+
* - Non-secret settings (team, workspace, sort): flag > env > project
|
|
6
|
+
* config > user config (`~/.config/linear/config.toml`) > the reference
|
|
7
|
+
* CLI's global config (`~/.config/linear/linear.toml`, read-only).
|
|
8
|
+
* The project config is the first file found walking cwd → filesystem
|
|
9
|
+
* root, checking `linear.toml`, `.linear.toml`, `.config/linear.toml` in
|
|
10
|
+
* each directory — every location schpet/linear-cli 2.5 reads (it checks
|
|
11
|
+
* cwd and the git root; we check every directory between, which is a
|
|
12
|
+
* superset), in its order.
|
|
13
|
+
* - The API key has a STRICTER boundary and is never read from a project file
|
|
14
|
+
* or from the reference CLI's global file (avoids committing secrets):
|
|
15
|
+
* flag > LINEAR_API_KEY env > user config (plaintext `api_key`) > OS keyring.
|
|
16
|
+
* A project `workspace` may select which already-stored credential to use;
|
|
17
|
+
* it can never provide or override the secret itself.
|
|
18
|
+
*
|
|
19
|
+
* Multi-workspace credentials live under quoted `[workspaces."<slug>"]` tables
|
|
20
|
+
* in the user config, with an optional top-level `default_workspace`. A table
|
|
21
|
+
* either carries the key itself (`api_key = "…"`, plaintext, 0600 file) or
|
|
22
|
+
* marks the slug as keyring-backed (`keyring = true`), in which case the secret
|
|
23
|
+
* lives in the OS keyring under service `linear-cli` / account `<slug>` — the
|
|
24
|
+
* reference CLI's convention, so a user migrating from it is authenticated
|
|
25
|
+
* before they run a single command. Its own workspace list
|
|
26
|
+
* (`credentials.toml`, a sibling of our file) is read for slugs and the
|
|
27
|
+
* default, never for keys. Credential selection is lazy: resolution never
|
|
28
|
+
* throws, stashing any selection error in `apiKeyError` so it surfaces only
|
|
29
|
+
* when a client is actually needed.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { homedir } from "node:os";
|
|
33
|
+
import { randomBytes } from "node:crypto";
|
|
34
|
+
import { join, dirname, basename, parse as parsePath } from "node:path";
|
|
35
|
+
import {
|
|
36
|
+
readFileSync,
|
|
37
|
+
existsSync,
|
|
38
|
+
mkdirSync,
|
|
39
|
+
writeFileSync,
|
|
40
|
+
chmodSync,
|
|
41
|
+
openSync,
|
|
42
|
+
closeSync,
|
|
43
|
+
fstatSync,
|
|
44
|
+
fsyncSync,
|
|
45
|
+
renameSync,
|
|
46
|
+
statSync,
|
|
47
|
+
unlinkSync,
|
|
48
|
+
} from "node:fs";
|
|
49
|
+
import { execFileSync } from "node:child_process";
|
|
50
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
51
|
+
import { CliError } from "./lib/errors.js";
|
|
52
|
+
import { keyring, KeyringError } from "./lib/keyring.js";
|
|
53
|
+
|
|
54
|
+
export interface RawSettings {
|
|
55
|
+
team?: string;
|
|
56
|
+
workspace?: string;
|
|
57
|
+
sort?: string;
|
|
58
|
+
apiKey?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Where a value came from. `user` is our `~/.config/linear/config.toml`;
|
|
63
|
+
* `global` is the reference CLI's `~/.config/linear/linear.toml`, read for
|
|
64
|
+
* non-secret settings only; `keychain` is the OS keyring (API key only).
|
|
65
|
+
*/
|
|
66
|
+
export type ConfigSource = "flag" | "env" | "project" | "user" | "global" | "keychain" | "none";
|
|
67
|
+
|
|
68
|
+
/** Where a stored workspace credential's secret lives. */
|
|
69
|
+
export type CredentialStorage = "file" | "keychain";
|
|
70
|
+
|
|
71
|
+
/** A setting's provenance: which tier, and (for a file) which file/key. */
|
|
72
|
+
export interface SettingOrigin {
|
|
73
|
+
source: ConfigSource;
|
|
74
|
+
path?: string;
|
|
75
|
+
/** Exact spelling found in the file (`issue_sort`, not just canonical `sort`). */
|
|
76
|
+
key?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The non-secret settings, each with its provenance. */
|
|
80
|
+
export type SettingOrigins = Record<"team" | "workspace" | "sort", SettingOrigin>;
|
|
81
|
+
|
|
82
|
+
export interface ResolvedConfig {
|
|
83
|
+
apiKey?: string;
|
|
84
|
+
apiKeySource: ConfigSource;
|
|
85
|
+
/**
|
|
86
|
+
* Deferred credential-selection error. Resolution is total (it never throws
|
|
87
|
+
* for selection problems) so commands that REPAIR auth state — `auth list`,
|
|
88
|
+
* `auth default`, `auth logout`, `auth login --workspace new-org` — can still
|
|
89
|
+
* build a Context. The error is surfaced only when an API client is actually
|
|
90
|
+
* needed (see createClient / `auth token`).
|
|
91
|
+
*/
|
|
92
|
+
apiKeyError?: CliError;
|
|
93
|
+
/** Workspace slug whose stored credential was used, if any. */
|
|
94
|
+
credentialWorkspace?: string;
|
|
95
|
+
team?: string;
|
|
96
|
+
/** Non-secret display workspace setting (separate from credentialWorkspace). */
|
|
97
|
+
workspace?: string;
|
|
98
|
+
sort: string;
|
|
99
|
+
/** Where `sort` came from, so an invalid value can be blamed precisely. */
|
|
100
|
+
sortSource: ConfigSource;
|
|
101
|
+
/** Provenance of every non-secret setting (`linear config` shows it). */
|
|
102
|
+
origins: SettingOrigins;
|
|
103
|
+
/** Absolute path of the user config file (may not exist yet). */
|
|
104
|
+
userConfigPath: string;
|
|
105
|
+
/** Project config path that was loaded, if any (see `findProjectConfig`). */
|
|
106
|
+
projectConfigPath?: string;
|
|
107
|
+
/** The reference CLI's global `linear.toml`, when it exists and was read. */
|
|
108
|
+
globalConfigPath?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface ConfigInputs {
|
|
112
|
+
/** CLI flag overrides. */
|
|
113
|
+
flags?: Partial<RawSettings>;
|
|
114
|
+
/** Defaults to process.env. */
|
|
115
|
+
env?: NodeJS.ProcessEnv;
|
|
116
|
+
/** Defaults to process.cwd(). */
|
|
117
|
+
cwd?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const USER_CONFIG_FILE = "config.toml";
|
|
121
|
+
|
|
122
|
+
/** Resolve the user config path lazily so tests can override XDG_CONFIG_HOME/HOME. */
|
|
123
|
+
export function userConfigPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
124
|
+
const base = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), ".config");
|
|
125
|
+
return join(base, "linear", USER_CONFIG_FILE);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The reference CLI's global config (`$XDG_CONFIG_HOME/linear/linear.toml`),
|
|
130
|
+
* a sibling of ours. Read for non-secret settings, below our own file; its
|
|
131
|
+
* `api_key`, if any, is ignored like a project file's.
|
|
132
|
+
*/
|
|
133
|
+
export function globalConfigPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
134
|
+
return join(dirname(userConfigPath(env)), "linear.toml");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The project config file names, in the order they are tried in each
|
|
139
|
+
* directory — the reference CLI's order, so a repo that has more than one
|
|
140
|
+
* resolves the same way under both tools.
|
|
141
|
+
*/
|
|
142
|
+
export const PROJECT_CONFIG_NAMES = [
|
|
143
|
+
"linear.toml",
|
|
144
|
+
".linear.toml",
|
|
145
|
+
join(".config", "linear.toml"),
|
|
146
|
+
] as const;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The reference CLI's credentials file, a sibling of our config. Its keyring
|
|
150
|
+
* format is `default = "slug"` + `workspaces = ["slug", …]` with the secrets in
|
|
151
|
+
* the OS keyring; its inline (plaintext) format is `slug = "lin_api_…"`, which
|
|
152
|
+
* we deliberately do NOT read — our secret sources are the flag, the env, our
|
|
153
|
+
* own 0600 file and the keyring, full stop.
|
|
154
|
+
*/
|
|
155
|
+
export function referenceCredentialsPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
156
|
+
return join(dirname(userConfigPath(env)), "credentials.toml");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** One stored workspace, as the user config knows it. */
|
|
160
|
+
interface WorkspaceEntry {
|
|
161
|
+
/** Plaintext key from `[workspaces."<slug>"].api_key`; absent → keyring-backed. */
|
|
162
|
+
apiKey?: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Parsed view of the user config: top-level settings + nested workspace creds. */
|
|
166
|
+
interface UserConfig {
|
|
167
|
+
/** Top-level non-secret settings. */
|
|
168
|
+
settings: RawSettings;
|
|
169
|
+
/** Exact top-level key spelling that supplied each non-secret setting. */
|
|
170
|
+
settingKeys: SettingKeys;
|
|
171
|
+
/** default_workspace, if set (ours, else the reference CLI's `default`). */
|
|
172
|
+
defaultWorkspace?: string;
|
|
173
|
+
/** slug → entry from `[workspaces."<slug>"]`, plus the reference CLI's list. */
|
|
174
|
+
workspaces: Record<string, WorkspaceEntry>;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
type NonSecretSetting = "team" | "workspace" | "sort";
|
|
178
|
+
type SettingKeys = Partial<Record<NonSecretSetting, string>>;
|
|
179
|
+
|
|
180
|
+
interface FileSettings {
|
|
181
|
+
settings: RawSettings;
|
|
182
|
+
keys: SettingKeys;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function asString(v: unknown): string | undefined {
|
|
186
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Strip control characters so nothing a config file contains can drive the
|
|
191
|
+
* terminal (ANSI escapes, `\r` overwrite tricks) or smuggle bidi overrides into
|
|
192
|
+
* an error message. C0, DEL, C1, and the bidi embedding/override controls —
|
|
193
|
+
* spelled as escapes so no literal control byte lives in this source.
|
|
194
|
+
*/
|
|
195
|
+
const CONTROL_CHARS = new RegExp(
|
|
196
|
+
// eslint-disable-next-line no-control-regex
|
|
197
|
+
"[\\u0000-\\u001F\\u007F-\\u009F\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u2069]",
|
|
198
|
+
"g",
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
function sanitize(text: string, max = 200): string {
|
|
202
|
+
const clean = text.replace(CONTROL_CHARS, "");
|
|
203
|
+
return clean.length > max ? clean.slice(0, max) + "…" : clean;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Describe a TOML parse failure WITHOUT quoting the file.
|
|
208
|
+
*
|
|
209
|
+
* smol-toml's `message` embeds a code block of the offending lines, so a
|
|
210
|
+
* truncated `api_key = "lin_api_…` line would print the secret to stderr, and a
|
|
211
|
+
* project-controlled `.linear.toml` could inject escape sequences the same way.
|
|
212
|
+
* We keep only the reason (a fixed string from smol-toml's own vocabulary) plus
|
|
213
|
+
* the position, and sanitize even that.
|
|
214
|
+
*/
|
|
215
|
+
function describeTomlError(err: unknown): string {
|
|
216
|
+
const reason = sanitize(String((err as Error)?.message ?? err).split("\n")[0] ?? "");
|
|
217
|
+
const { line, column } = err as { line?: unknown; column?: unknown };
|
|
218
|
+
const where =
|
|
219
|
+
typeof line === "number" && typeof column === "number"
|
|
220
|
+
? ` (line ${line}, column ${column})`
|
|
221
|
+
: "";
|
|
222
|
+
return (reason || "invalid TOML") + where;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Raw parse of a TOML file into a plain object, with a friendly error. */
|
|
226
|
+
function parseTomlFile(path: string): Record<string, unknown> {
|
|
227
|
+
let text: string;
|
|
228
|
+
try {
|
|
229
|
+
text = readFileSync(path, "utf8");
|
|
230
|
+
} catch (err) {
|
|
231
|
+
throw new CliError(
|
|
232
|
+
`Cannot read config at ${sanitize(path, 512)}: ${sanitize((err as Error).message)}`,
|
|
233
|
+
"runtime",
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
return parseToml(text) as Record<string, unknown>;
|
|
238
|
+
} catch (err) {
|
|
239
|
+
throw new CliError(
|
|
240
|
+
`Failed to parse config at ${sanitize(path, 512)}: ${describeTomlError(err)}`,
|
|
241
|
+
"runtime",
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Read flat NON-SECRET settings from a TOML file. Used for project
|
|
248
|
+
* `.linear.toml`; the API key is intentionally NOT read here (trust boundary).
|
|
249
|
+
*/
|
|
250
|
+
function settingsFromParsed(parsed: Record<string, unknown>): FileSettings {
|
|
251
|
+
const read = (canonical: NonSecretSetting, aliases: readonly string[]) => {
|
|
252
|
+
// Match the old `primary ?? alias` behavior exactly: a present-but-invalid
|
|
253
|
+
// primary spelling does not silently fall through to a second key.
|
|
254
|
+
const key = aliases.find((alias) => parsed[alias] !== undefined);
|
|
255
|
+
const value = key === undefined ? undefined : asString(parsed[key]);
|
|
256
|
+
return { canonical, key: value === undefined ? undefined : key, value };
|
|
257
|
+
};
|
|
258
|
+
const found = [
|
|
259
|
+
read("team", ["team", "team_id"]),
|
|
260
|
+
read("workspace", ["workspace"]),
|
|
261
|
+
read("sort", ["sort", "issue_sort"]),
|
|
262
|
+
];
|
|
263
|
+
const settings: RawSettings = {};
|
|
264
|
+
const keys: SettingKeys = {};
|
|
265
|
+
for (const item of found) {
|
|
266
|
+
if (item.value !== undefined) settings[item.canonical] = item.value;
|
|
267
|
+
if (item.key !== undefined) keys[item.canonical] = item.key;
|
|
268
|
+
}
|
|
269
|
+
return { settings, keys };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function readTomlFile(path: string): FileSettings {
|
|
273
|
+
const parsed = parseTomlFile(path);
|
|
274
|
+
return settingsFromParsed(parsed);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The workspace slugs (and default) the reference CLI's `credentials.toml`
|
|
279
|
+
* lists, when it is in keyring format. Its inline format is ignored entirely:
|
|
280
|
+
* listing those slugs without reading their keys would only produce a
|
|
281
|
+
* "no stored credential" error for a workspace the user can see in the file.
|
|
282
|
+
*/
|
|
283
|
+
function readReferenceCredentials(path: string): {
|
|
284
|
+
workspaces: string[];
|
|
285
|
+
defaultWorkspace?: string;
|
|
286
|
+
} {
|
|
287
|
+
if (!existsSync(path)) return { workspaces: [] };
|
|
288
|
+
const parsed = parseTomlFile(path);
|
|
289
|
+
const list = parsed.workspaces;
|
|
290
|
+
if (!Array.isArray(list)) return { workspaces: [] };
|
|
291
|
+
const workspaces = [
|
|
292
|
+
...new Set(list.filter((v): v is string => typeof v === "string" && v.length > 0)),
|
|
293
|
+
];
|
|
294
|
+
const def = asString(parsed.default);
|
|
295
|
+
return {
|
|
296
|
+
workspaces,
|
|
297
|
+
// The reference CLI itself ignores a default that is not in the list.
|
|
298
|
+
defaultWorkspace: def && workspaces.includes(def) ? def : undefined,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Read the user config into its structured form (settings + workspaces). */
|
|
303
|
+
function readUserConfig(path: string): UserConfig {
|
|
304
|
+
const workspaces: Record<string, WorkspaceEntry> = {};
|
|
305
|
+
const parsed = existsSync(path) ? parseTomlFile(path) : {};
|
|
306
|
+
const fileSettings = settingsFromParsed(parsed);
|
|
307
|
+
const wsTable = parsed.workspaces;
|
|
308
|
+
if (wsTable && typeof wsTable === "object") {
|
|
309
|
+
for (const [slug, val] of Object.entries(wsTable as Record<string, unknown>)) {
|
|
310
|
+
if (val && typeof val === "object") {
|
|
311
|
+
// A table with no api_key is a keyring-backed workspace (whether or
|
|
312
|
+
// not it carries the `keyring = true` marker `auth login` writes).
|
|
313
|
+
workspaces[slug] = { apiKey: asString((val as Record<string, unknown>).api_key) };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// Then the reference CLI's list, so its keyring entries are found without a
|
|
318
|
+
// re-login. Ours wins on a shared slug (a plaintext key here beats the keyring).
|
|
319
|
+
const reference = readReferenceCredentials(join(dirname(path), "credentials.toml"));
|
|
320
|
+
for (const slug of reference.workspaces) workspaces[slug] ??= {};
|
|
321
|
+
return {
|
|
322
|
+
settings: fileSettings.settings,
|
|
323
|
+
settingKeys: fileSettings.keys,
|
|
324
|
+
defaultWorkspace: asString(parsed.default_workspace) ?? reference.defaultWorkspace,
|
|
325
|
+
workspaces,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The secret for a workspace: the plaintext key from our file if there is
|
|
331
|
+
* one, else the keyring entry. Returns the source alongside so `auth status`
|
|
332
|
+
* can say which. A keyring failure that is not "no such item" is reported as
|
|
333
|
+
* an error rather than mistaken for a missing credential.
|
|
334
|
+
*/
|
|
335
|
+
function lookupCredential(
|
|
336
|
+
user: UserConfig,
|
|
337
|
+
slug: string,
|
|
338
|
+
): { apiKey: string; source: "user" | "keychain" } | { error: CliError } | undefined {
|
|
339
|
+
const plaintext = user.workspaces[slug]?.apiKey;
|
|
340
|
+
if (plaintext) return { apiKey: plaintext, source: "user" };
|
|
341
|
+
const backend = keyring();
|
|
342
|
+
if (!backend) return undefined;
|
|
343
|
+
try {
|
|
344
|
+
const secret = backend.get(slug);
|
|
345
|
+
return secret ? { apiKey: secret, source: "keychain" } : undefined;
|
|
346
|
+
} catch (err) {
|
|
347
|
+
if (err instanceof KeyringError) {
|
|
348
|
+
return {
|
|
349
|
+
error: new CliError(
|
|
350
|
+
`Could not read the ${backend.label} entry for workspace '${slug}': ${err.message}`,
|
|
351
|
+
"runtime",
|
|
352
|
+
),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
throw err;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Walk from cwd up to the filesystem root and return the first project config:
|
|
361
|
+
* in each directory `linear.toml`, then `.linear.toml`, then
|
|
362
|
+
* `.config/linear.toml`. That is every file the reference CLI reads (it looks
|
|
363
|
+
* in cwd and at the git root; every directory in between is a superset that
|
|
364
|
+
* agrees with it wherever it would find something) — so a repo set up with its
|
|
365
|
+
* `linear config`, which prefers `<gitroot>/.config/linear.toml`, just works.
|
|
366
|
+
*/
|
|
367
|
+
export function findProjectConfig(cwd: string): string | undefined {
|
|
368
|
+
let dir = cwd;
|
|
369
|
+
const { root } = parsePath(dir);
|
|
370
|
+
// Bound the walk so a pathological symlink loop can't spin forever.
|
|
371
|
+
for (let i = 0; i < 64; i++) {
|
|
372
|
+
for (const name of PROJECT_CONFIG_NAMES) {
|
|
373
|
+
const candidate = join(dir, name);
|
|
374
|
+
if (existsSync(candidate)) return candidate;
|
|
375
|
+
}
|
|
376
|
+
if (dir === root) break;
|
|
377
|
+
const parent = dirname(dir);
|
|
378
|
+
if (parent === dir) break;
|
|
379
|
+
dir = parent;
|
|
380
|
+
}
|
|
381
|
+
return undefined;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Read env vars into a settings object (only the keys we recognize). */
|
|
385
|
+
function fromEnv(env: NodeJS.ProcessEnv): RawSettings {
|
|
386
|
+
return {
|
|
387
|
+
apiKey: env.LINEAR_API_KEY || env.LINEAR_API_TOKEN,
|
|
388
|
+
team: env.LINEAR_TEAM || env.LINEAR_TEAM_ID,
|
|
389
|
+
workspace: env.LINEAR_WORKSPACE,
|
|
390
|
+
sort: env.LINEAR_ISSUE_SORT,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
395
|
+
const env = inputs.env ?? process.env;
|
|
396
|
+
const cwd = inputs.cwd ?? process.cwd();
|
|
397
|
+
const flags = inputs.flags ?? {};
|
|
398
|
+
|
|
399
|
+
const envSettings = fromEnv(env);
|
|
400
|
+
const userPath = userConfigPath(env);
|
|
401
|
+
const user = readUserConfig(userPath);
|
|
402
|
+
const projectPath = findProjectConfig(cwd);
|
|
403
|
+
const project = projectPath ? readTomlFile(projectPath) : { settings: {}, keys: {} };
|
|
404
|
+
// The reference CLI's global file: same non-secret reader as a project file,
|
|
405
|
+
// so its `api_key` (it does allow one there) is never picked up.
|
|
406
|
+
const globalCandidate = globalConfigPath(env);
|
|
407
|
+
const globalPath = existsSync(globalCandidate) ? globalCandidate : undefined;
|
|
408
|
+
const global = globalPath ? readTomlFile(globalPath) : { settings: {}, keys: {} };
|
|
409
|
+
|
|
410
|
+
// ----- API key resolution (strict trust boundary) -----------------------
|
|
411
|
+
// Resolution is TOTAL: selection problems are stashed in `apiKeyError` rather
|
|
412
|
+
// than thrown, so auth-repair commands can still build a Context. The error
|
|
413
|
+
// is surfaced lazily, when an API client is actually needed.
|
|
414
|
+
let apiKey: string | undefined;
|
|
415
|
+
let apiKeySource: ConfigSource = "none";
|
|
416
|
+
let credentialWorkspace: string | undefined;
|
|
417
|
+
let apiKeyError: CliError | undefined;
|
|
418
|
+
|
|
419
|
+
if (flags.apiKey) {
|
|
420
|
+
// 1. An explicit flag is absolute: it bypasses all workspace selection.
|
|
421
|
+
apiKey = flags.apiKey;
|
|
422
|
+
apiKeySource = "flag";
|
|
423
|
+
} else if (envSettings.apiKey) {
|
|
424
|
+
// 1. LINEAR_API_KEY/LINEAR_API_TOKEN is absolute too.
|
|
425
|
+
apiKey = envSettings.apiKey;
|
|
426
|
+
apiKeySource = "env";
|
|
427
|
+
} else {
|
|
428
|
+
// 2. Otherwise compute the credential workspace. A project may select an
|
|
429
|
+
// already-stored credential by its non-secret slug, but cannot supply a
|
|
430
|
+
// key: lookupCredential only reads the user config and OS keyring.
|
|
431
|
+
const selected =
|
|
432
|
+
flags.workspace ??
|
|
433
|
+
env.LINEAR_WORKSPACE ??
|
|
434
|
+
project.settings.workspace ??
|
|
435
|
+
user.defaultWorkspace;
|
|
436
|
+
|
|
437
|
+
// Apply a lookup result for the slug the selection settled on. An
|
|
438
|
+
// explicitly named slug is probed even when no file lists it — the keyring
|
|
439
|
+
// entry may be all the user has (the reference CLI's, say, with its list
|
|
440
|
+
// file gone) — but a listed slug with nothing behind it is a stale entry.
|
|
441
|
+
const take = (slug: string) => {
|
|
442
|
+
const found = lookupCredential(user, slug);
|
|
443
|
+
if (!found) {
|
|
444
|
+
apiKeyError = new CliError(
|
|
445
|
+
`No stored credential for workspace '${slug}'. Run \`linear auth list\` to see configured workspaces, or \`linear auth login --workspace ${slug}\`.`,
|
|
446
|
+
"not_found",
|
|
447
|
+
);
|
|
448
|
+
} else if ("error" in found) {
|
|
449
|
+
apiKeyError = found.error;
|
|
450
|
+
} else {
|
|
451
|
+
apiKey = found.apiKey;
|
|
452
|
+
apiKeySource = found.source;
|
|
453
|
+
credentialWorkspace = slug;
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
if (selected) {
|
|
458
|
+
// 3. A workspace was selected: it must have a stored credential.
|
|
459
|
+
take(selected);
|
|
460
|
+
} else {
|
|
461
|
+
// 4. No selection: use the sole workspace if exactly one is configured.
|
|
462
|
+
const slugs = Object.keys(user.workspaces);
|
|
463
|
+
if (slugs.length === 1) {
|
|
464
|
+
take(slugs[0]!);
|
|
465
|
+
} else if (slugs.length > 1) {
|
|
466
|
+
// Multiple workspaces but no default → ambiguous (deferred).
|
|
467
|
+
apiKeyError = new CliError(
|
|
468
|
+
`Multiple workspaces are configured (${slugs.join(", ")}) but none is selected. Pass --workspace <slug> or run \`linear auth default <slug>\`.`,
|
|
469
|
+
"usage",
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
// else: no key at all (unchanged "no API key" path).
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ----- Non-secret settings (project + global config MAY participate) -----
|
|
477
|
+
// flag > env > project file > our user file > the reference CLI's global file.
|
|
478
|
+
const tiers: Array<[ConfigSource, RawSettings, string | undefined, SettingKeys | undefined]> = [
|
|
479
|
+
["flag", flags, undefined, undefined],
|
|
480
|
+
["env", envSettings, undefined, undefined],
|
|
481
|
+
["project", project.settings, projectPath, project.keys],
|
|
482
|
+
["user", user.settings, userPath, user.settingKeys],
|
|
483
|
+
["global", global.settings, globalPath, global.keys],
|
|
484
|
+
];
|
|
485
|
+
const pick = <K extends "team" | "workspace" | "sort">(key: K): string | undefined =>
|
|
486
|
+
tiers.find(([, settings]) => settings[key] !== undefined)?.[1][key];
|
|
487
|
+
|
|
488
|
+
/** Which tier `pick` took the value from, and which file if it was one. */
|
|
489
|
+
const originOf = (key: "team" | "workspace" | "sort"): SettingOrigin => {
|
|
490
|
+
const hit = tiers.find(([, settings]) => settings[key] !== undefined);
|
|
491
|
+
if (!hit) return { source: "none" };
|
|
492
|
+
const origin: SettingOrigin = { source: hit[0], path: hit[2] };
|
|
493
|
+
const spelling = hit[3]?.[key];
|
|
494
|
+
if (spelling !== undefined) origin.key = spelling;
|
|
495
|
+
return origin;
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const origins: SettingOrigins = {
|
|
499
|
+
team: originOf("team"),
|
|
500
|
+
workspace: originOf("workspace"),
|
|
501
|
+
sort: originOf("sort"),
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
apiKey,
|
|
506
|
+
apiKeySource,
|
|
507
|
+
apiKeyError,
|
|
508
|
+
credentialWorkspace,
|
|
509
|
+
team: pick("team"),
|
|
510
|
+
// The display setting walks every tier. Credential selection above uses
|
|
511
|
+
// the flag, env, and project tiers, then the auth-specific user default.
|
|
512
|
+
workspace: pick("workspace"),
|
|
513
|
+
sort: pick("sort") ?? "priority",
|
|
514
|
+
sortSource: origins.sort.source,
|
|
515
|
+
origins,
|
|
516
|
+
userConfigPath: userPath,
|
|
517
|
+
projectConfigPath: projectPath,
|
|
518
|
+
globalConfigPath: globalPath,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ---------------------------------------------------------------------------
|
|
523
|
+
// Structured TOML writers
|
|
524
|
+
//
|
|
525
|
+
// We round-trip through a plain object and smol-toml's `stringify` so nested
|
|
526
|
+
// `[workspaces."<slug>"]` tables and other settings are preserved. smol-toml
|
|
527
|
+
// quotes keys only when required, but hyphenated slugs are valid bare TOML
|
|
528
|
+
// keys and round-trip correctly.
|
|
529
|
+
// ---------------------------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
/** Read the full user config as a mutable plain object (or {} if absent). */
|
|
532
|
+
function readUserObject(path: string): Record<string, unknown> {
|
|
533
|
+
if (!existsSync(path)) return {};
|
|
534
|
+
return parseTomlFile(path);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// A rename makes each individual write atomic, but does not make a whole
|
|
538
|
+
// read/modify/write transaction atomic: two processes can read the same old
|
|
539
|
+
// object and then replace one another's changes. User-config mutations take a
|
|
540
|
+
// short-lived advisory lock around that entire transaction. Reads remain
|
|
541
|
+
// lock-free and continue to see either the old complete file or the new one.
|
|
542
|
+
const USER_CONFIG_LOCK_TIMEOUT_MS = 5_000;
|
|
543
|
+
const USER_CONFIG_LOCK_STALE_MS = 30_000;
|
|
544
|
+
const USER_CONFIG_LOCK_POLL_MS = 20;
|
|
545
|
+
const LOCK_SLEEP = new Int32Array(new SharedArrayBuffer(4));
|
|
546
|
+
|
|
547
|
+
interface AdvisoryLock {
|
|
548
|
+
path: string;
|
|
549
|
+
fd: number;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
interface LockSnapshot {
|
|
553
|
+
dev: number;
|
|
554
|
+
ino: number;
|
|
555
|
+
mtimeMs: number;
|
|
556
|
+
pid?: number;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function errorCode(err: unknown): string | undefined {
|
|
560
|
+
return (err as NodeJS.ErrnoException)?.code;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Create one exclusive lock marker, returning undefined when it already exists. */
|
|
564
|
+
function createLockMarker(path: string): AdvisoryLock | undefined {
|
|
565
|
+
let fd: number;
|
|
566
|
+
try {
|
|
567
|
+
fd = openSync(path, "wx", 0o600);
|
|
568
|
+
} catch (err) {
|
|
569
|
+
if (errorCode(err) === "EEXIST") return undefined;
|
|
570
|
+
throw err;
|
|
571
|
+
}
|
|
572
|
+
try {
|
|
573
|
+
writeFileSync(
|
|
574
|
+
fd,
|
|
575
|
+
JSON.stringify({
|
|
576
|
+
pid: process.pid,
|
|
577
|
+
createdAt: Date.now(),
|
|
578
|
+
token: randomBytes(12).toString("hex"),
|
|
579
|
+
}) + "\n",
|
|
580
|
+
"utf8",
|
|
581
|
+
);
|
|
582
|
+
fsyncSync(fd);
|
|
583
|
+
return { path, fd };
|
|
584
|
+
} catch (err) {
|
|
585
|
+
releaseLockMarker({ path, fd });
|
|
586
|
+
throw err;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Release only the path that still names our inode, never a successor's lock. */
|
|
591
|
+
function releaseLockMarker(lock: AdvisoryLock): void {
|
|
592
|
+
try {
|
|
593
|
+
const held = fstatSync(lock.fd);
|
|
594
|
+
try {
|
|
595
|
+
const current = statSync(lock.path);
|
|
596
|
+
if (current.dev === held.dev && current.ino === held.ino) unlinkSync(lock.path);
|
|
597
|
+
} catch (err) {
|
|
598
|
+
if (errorCode(err) !== "ENOENT") throw err;
|
|
599
|
+
}
|
|
600
|
+
} finally {
|
|
601
|
+
closeSync(lock.fd);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Open a lock marker as one immutable inode and read its owner metadata. */
|
|
606
|
+
function readLockSnapshot(path: string): LockSnapshot | undefined {
|
|
607
|
+
let fd: number;
|
|
608
|
+
try {
|
|
609
|
+
fd = openSync(path, "r");
|
|
610
|
+
} catch (err) {
|
|
611
|
+
if (errorCode(err) === "ENOENT") return undefined;
|
|
612
|
+
throw err;
|
|
613
|
+
}
|
|
614
|
+
try {
|
|
615
|
+
const stat = fstatSync(fd);
|
|
616
|
+
let pid: number | undefined;
|
|
617
|
+
try {
|
|
618
|
+
const parsed = JSON.parse(readFileSync(fd, "utf8")) as { pid?: unknown };
|
|
619
|
+
if (Number.isSafeInteger(parsed.pid) && (parsed.pid as number) > 0) {
|
|
620
|
+
pid = parsed.pid as number;
|
|
621
|
+
}
|
|
622
|
+
} catch {
|
|
623
|
+
// A creator can briefly expose an empty marker before writing metadata.
|
|
624
|
+
// It is considered active until its mtime crosses the stale bound.
|
|
625
|
+
}
|
|
626
|
+
return { dev: stat.dev, ino: stat.ino, mtimeMs: stat.mtimeMs, pid };
|
|
627
|
+
} finally {
|
|
628
|
+
closeSync(fd);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function processIsAlive(pid: number): boolean {
|
|
633
|
+
try {
|
|
634
|
+
process.kill(pid, 0);
|
|
635
|
+
return true;
|
|
636
|
+
} catch (err) {
|
|
637
|
+
// EPERM still proves the process exists; ESRCH (and an invalid/out-of-range
|
|
638
|
+
// PID from a corrupt marker) means nobody can own this lock now.
|
|
639
|
+
return errorCode(err) === "EPERM";
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function lockIsStale(snapshot: LockSnapshot): boolean {
|
|
644
|
+
if (snapshot.pid !== undefined) return !processIsAlive(snapshot.pid);
|
|
645
|
+
return Date.now() - snapshot.mtimeMs >= USER_CONFIG_LOCK_STALE_MS;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Unlink only if the path still names the stale inode we inspected. */
|
|
649
|
+
function unlinkSnapshot(path: string, snapshot: LockSnapshot): boolean {
|
|
650
|
+
try {
|
|
651
|
+
const current = statSync(path);
|
|
652
|
+
if (current.dev !== snapshot.dev || current.ino !== snapshot.ino) return false;
|
|
653
|
+
unlinkSync(path);
|
|
654
|
+
return true;
|
|
655
|
+
} catch (err) {
|
|
656
|
+
if (errorCode(err) === "ENOENT") return false;
|
|
657
|
+
throw err;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Reap a dead owner's lock under a second exclusive marker. The reaper marker
|
|
663
|
+
* prevents several waiting writers from deleting a newly acquired successor
|
|
664
|
+
* while they all race to clean up the same stale inode.
|
|
665
|
+
*/
|
|
666
|
+
function reapStaleLock(path: string): boolean {
|
|
667
|
+
const candidate = readLockSnapshot(path);
|
|
668
|
+
if (!candidate || !lockIsStale(candidate)) return false;
|
|
669
|
+
|
|
670
|
+
const reapPath = `${path}.reap`;
|
|
671
|
+
const reaper = createLockMarker(reapPath);
|
|
672
|
+
if (!reaper) {
|
|
673
|
+
// A reaper can itself die in its tiny critical section. Recover it with
|
|
674
|
+
// the same PID/age rules; a later poll will then claim it normally.
|
|
675
|
+
const staleReaper = readLockSnapshot(reapPath);
|
|
676
|
+
if (staleReaper && lockIsStale(staleReaper)) unlinkSnapshot(reapPath, staleReaper);
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
try {
|
|
680
|
+
const snapshot = readLockSnapshot(path);
|
|
681
|
+
return snapshot !== undefined && lockIsStale(snapshot) && unlinkSnapshot(path, snapshot);
|
|
682
|
+
} finally {
|
|
683
|
+
releaseLockMarker(reaper);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function acquireUserConfigLock(path: string): AdvisoryLock {
|
|
688
|
+
const lockPath = `${path}.lock`;
|
|
689
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
690
|
+
const deadline = performance.now() + USER_CONFIG_LOCK_TIMEOUT_MS;
|
|
691
|
+
while (true) {
|
|
692
|
+
const lock = createLockMarker(lockPath);
|
|
693
|
+
if (lock) return lock;
|
|
694
|
+
if (reapStaleLock(lockPath)) continue;
|
|
695
|
+
const remaining = deadline - performance.now();
|
|
696
|
+
if (remaining <= 0) {
|
|
697
|
+
throw new CliError(
|
|
698
|
+
`Timed out waiting for another process to finish updating ${sanitize(path, 512)}. Retry the command once that config write completes.`,
|
|
699
|
+
"runtime",
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
Atomics.wait(LOCK_SLEEP, 0, 0, Math.min(USER_CONFIG_LOCK_POLL_MS, remaining));
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function withUserConfigLock<T>(path: string, mutate: () => T): T {
|
|
707
|
+
const lock = acquireUserConfigLock(path);
|
|
708
|
+
try {
|
|
709
|
+
return mutate();
|
|
710
|
+
} finally {
|
|
711
|
+
releaseLockMarker(lock);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Persist text ATOMICALLY, with the given perms.
|
|
717
|
+
*
|
|
718
|
+
* Writing in place truncates the file first, so a crash — or a concurrent
|
|
719
|
+
* reader, or a second `linear auth login` — could see a half-written config and
|
|
720
|
+
* lose every stored credential. Instead we write a temp file in the same
|
|
721
|
+
* directory (same filesystem, so the rename is atomic), fsync it, and rename it
|
|
722
|
+
* over the target: a reader sees either the old config or the new one, never a
|
|
723
|
+
* torn one. The temp file is created with `mode` from the start, so a 0600 key
|
|
724
|
+
* file is never momentarily readable by anyone else — and the mode is asserted
|
|
725
|
+
* rather than inherited, so a config that was loosened by hand is tightened
|
|
726
|
+
* again.
|
|
727
|
+
*/
|
|
728
|
+
function writeFileAtomic(path: string, body: string, mode: number): void {
|
|
729
|
+
const dir = dirname(path);
|
|
730
|
+
mkdirSync(dir, { recursive: true });
|
|
731
|
+
|
|
732
|
+
const tmp = join(dir, `.${basename(path)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
|
|
733
|
+
try {
|
|
734
|
+
// 'wx' refuses to clobber, so two racing writers cannot share a temp file.
|
|
735
|
+
const fd = openSync(tmp, "wx", mode);
|
|
736
|
+
try {
|
|
737
|
+
writeFileSync(fd, body, "utf8");
|
|
738
|
+
fsyncSync(fd);
|
|
739
|
+
} finally {
|
|
740
|
+
closeSync(fd);
|
|
741
|
+
}
|
|
742
|
+
// The create mode is masked by umask; restate it before the file is linked
|
|
743
|
+
// into place under its real name.
|
|
744
|
+
chmodSync(tmp, mode);
|
|
745
|
+
renameSync(tmp, path);
|
|
746
|
+
} catch (err) {
|
|
747
|
+
try {
|
|
748
|
+
unlinkSync(tmp);
|
|
749
|
+
} catch {
|
|
750
|
+
// Nothing to clean up.
|
|
751
|
+
}
|
|
752
|
+
throw err;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** Serialize + persist the user config object atomically, 0600 (it holds keys). */
|
|
757
|
+
function writeUserObject(path: string, obj: Record<string, unknown>): void {
|
|
758
|
+
writeFileAtomic(path, stringifyToml(obj) + "\n", 0o600);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
interface WorkspaceTable {
|
|
762
|
+
api_key?: string;
|
|
763
|
+
keyring?: boolean;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function workspacesTable(obj: Record<string, unknown>): Record<string, WorkspaceTable> {
|
|
767
|
+
if (!obj.workspaces || typeof obj.workspaces !== "object") obj.workspaces = {};
|
|
768
|
+
return obj.workspaces as Record<string, WorkspaceTable>;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
export interface WriteCredentialResult {
|
|
772
|
+
/** Our config file — always written (the slug list lives there). */
|
|
773
|
+
path: string;
|
|
774
|
+
/** Where the secret itself went. */
|
|
775
|
+
storage: CredentialStorage;
|
|
776
|
+
/** Human label of the keyring used, when `storage` is "keychain". */
|
|
777
|
+
keyringLabel?: string;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** A single, explicitly named credential found in the shared OS keyring. */
|
|
781
|
+
export interface KeyringCredentialProbe {
|
|
782
|
+
/** Kept in memory only; callers must never print it or pass it on argv. */
|
|
783
|
+
apiKey: string;
|
|
784
|
+
keyringLabel: string;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Read exactly one workspace account from the shared keyring.
|
|
789
|
+
*
|
|
790
|
+
* This is intentionally not a discovery API: keyring enumeration can expose
|
|
791
|
+
* unrelated account names and secrets. `auth adopt <slug>` asks the user for
|
|
792
|
+
* the non-secret account name and probes only service `linear-cli` / that
|
|
793
|
+
* account, matching the reference CLI's storage convention.
|
|
794
|
+
*/
|
|
795
|
+
export function probeKeyringCredential(slug: string): KeyringCredentialProbe {
|
|
796
|
+
const backend = keyring();
|
|
797
|
+
if (!backend) {
|
|
798
|
+
throw new CliError(
|
|
799
|
+
`No usable system keyring is available, so workspace '${sanitize(slug)}' cannot be adopted. Run \`linear auth login --key -\` to read the key from stdin instead.`,
|
|
800
|
+
"runtime",
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
let apiKey: string | null;
|
|
804
|
+
try {
|
|
805
|
+
apiKey = backend.get(slug);
|
|
806
|
+
} catch (err) {
|
|
807
|
+
if (err instanceof KeyringError) {
|
|
808
|
+
throw new CliError(
|
|
809
|
+
`Could not read the ${backend.label} entry for workspace '${sanitize(slug)}': ${err.message}`,
|
|
810
|
+
"runtime",
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
throw err;
|
|
814
|
+
}
|
|
815
|
+
if (!apiKey) {
|
|
816
|
+
throw new CliError(
|
|
817
|
+
`No ${backend.label} credential exists for workspace '${sanitize(slug)}' (service linear-cli, account '${sanitize(slug)}'). Run \`linear auth login --key -\` to store one from stdin.`,
|
|
818
|
+
"not_found",
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
return { apiKey, keyringLabel: backend.label };
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
export interface AdoptCredentialResult {
|
|
825
|
+
path: string;
|
|
826
|
+
storage: "keychain";
|
|
827
|
+
keyringLabel: string;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Record a previously validated, explicitly named keyring credential in our
|
|
832
|
+
* workspace list. The secret remains in the keyring and is never serialized.
|
|
833
|
+
* A second exact-name probe inside the config lock prevents a changed or
|
|
834
|
+
* removed key from being adopted after validation.
|
|
835
|
+
*/
|
|
836
|
+
export function adoptKeyringCredential(
|
|
837
|
+
slug: string,
|
|
838
|
+
expectedApiKey: string,
|
|
839
|
+
): AdoptCredentialResult {
|
|
840
|
+
const path = userConfigPath();
|
|
841
|
+
return withUserConfigLock(path, () => {
|
|
842
|
+
const found = probeKeyringCredential(slug);
|
|
843
|
+
if (found.apiKey !== expectedApiKey) {
|
|
844
|
+
throw new CliError(
|
|
845
|
+
`The ${found.keyringLabel} credential for workspace '${sanitize(slug)}' changed while it was being validated. Retry the adopt command.`,
|
|
846
|
+
"runtime",
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const obj = readUserObject(path);
|
|
851
|
+
const ws = workspacesTable(obj);
|
|
852
|
+
const table: WorkspaceTable = { ...(ws[slug] ?? {}) };
|
|
853
|
+
delete table.api_key;
|
|
854
|
+
table.keyring = true;
|
|
855
|
+
ws[slug] = table;
|
|
856
|
+
if (!asString(obj.default_workspace)) {
|
|
857
|
+
const reference = readReferenceCredentials(referenceCredentialsPath());
|
|
858
|
+
if (!reference.defaultWorkspace) obj.default_workspace = slug;
|
|
859
|
+
}
|
|
860
|
+
writeUserObject(path, obj);
|
|
861
|
+
return { path, storage: "keychain", keyringLabel: found.keyringLabel };
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Store a credential for `slug`. By default the secret goes to the OS keyring
|
|
867
|
+
* (service `linear-cli`, account = slug) and the file only records
|
|
868
|
+
* `[workspaces."<slug>"] keyring = true`; with `plaintext`, or where there is
|
|
869
|
+
* no keyring, it is written as `api_key` in the 0600 file — the latter without
|
|
870
|
+
* complaint, since a platform is not an error. Either way the OTHER form for
|
|
871
|
+
* the same slug is dropped from the file, so a re-login moves the key rather
|
|
872
|
+
* than leaving a plaintext copy behind that would keep winning. If no default
|
|
873
|
+
* workspace exists anywhere (ours or the reference CLI's), this slug becomes it.
|
|
874
|
+
*/
|
|
875
|
+
export function writeCredential(
|
|
876
|
+
slug: string,
|
|
877
|
+
apiKey: string,
|
|
878
|
+
opts: { plaintext?: boolean } = {},
|
|
879
|
+
): WriteCredentialResult {
|
|
880
|
+
const path = userConfigPath();
|
|
881
|
+
return withUserConfigLock(path, () => {
|
|
882
|
+
const backend = opts.plaintext ? null : keyring();
|
|
883
|
+
const obj = readUserObject(path);
|
|
884
|
+
const ws = workspacesTable(obj);
|
|
885
|
+
const table: WorkspaceTable = { ...(ws[slug] ?? {}) };
|
|
886
|
+
let storage: CredentialStorage;
|
|
887
|
+
if (backend) {
|
|
888
|
+
// Keyring first: if it refuses, the file is untouched and the error is loud.
|
|
889
|
+
backend.set(slug, apiKey);
|
|
890
|
+
delete table.api_key;
|
|
891
|
+
table.keyring = true;
|
|
892
|
+
storage = "keychain";
|
|
893
|
+
} else {
|
|
894
|
+
delete table.keyring;
|
|
895
|
+
table.api_key = apiKey;
|
|
896
|
+
storage = "file";
|
|
897
|
+
}
|
|
898
|
+
ws[slug] = table;
|
|
899
|
+
if (!asString(obj.default_workspace)) {
|
|
900
|
+
// Leave the reference CLI's default in charge if it has one; writing ours
|
|
901
|
+
// over it would silently switch a migrating user's workspace.
|
|
902
|
+
const reference = readReferenceCredentials(referenceCredentialsPath());
|
|
903
|
+
if (!reference.defaultWorkspace) obj.default_workspace = slug;
|
|
904
|
+
}
|
|
905
|
+
writeUserObject(path, obj);
|
|
906
|
+
return { path, storage, keyringLabel: backend?.label };
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** Set `default_workspace`. Errors if that workspace is not configured anywhere. */
|
|
911
|
+
export function setDefaultWorkspace(slug: string): string {
|
|
912
|
+
const path = userConfigPath();
|
|
913
|
+
return withUserConfigLock(path, () => {
|
|
914
|
+
const known = readUserConfig(path).workspaces;
|
|
915
|
+
if (!known[slug]) {
|
|
916
|
+
throw new CliError(
|
|
917
|
+
`Workspace '${slug}' is not configured. Run \`linear auth login --workspace ${slug}\` first.`,
|
|
918
|
+
"not_found",
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
const obj = readUserObject(path);
|
|
922
|
+
obj.default_workspace = slug;
|
|
923
|
+
writeUserObject(path, obj);
|
|
924
|
+
return path;
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Drop `slug` from the reference CLI's keyring-format credentials list, if it
|
|
930
|
+
* is there, in that file's own layout (`default` first, sorted `workspaces`).
|
|
931
|
+
* Left alone, the entry would make BOTH CLIs report a workspace whose keyring
|
|
932
|
+
* secret is gone. Returns true if the file changed.
|
|
933
|
+
*/
|
|
934
|
+
function removeFromReferenceCredentials(slug: string): boolean {
|
|
935
|
+
const path = referenceCredentialsPath();
|
|
936
|
+
const reference = readReferenceCredentials(path);
|
|
937
|
+
if (!reference.workspaces.includes(slug)) return false;
|
|
938
|
+
const workspaces = reference.workspaces.filter((s) => s !== slug).sort();
|
|
939
|
+
const def = reference.defaultWorkspace === slug ? workspaces[0] : reference.defaultWorkspace;
|
|
940
|
+
const obj: Record<string, unknown> = {};
|
|
941
|
+
if (def) obj.default = def;
|
|
942
|
+
obj.workspaces = workspaces;
|
|
943
|
+
writeUserObject(path, obj);
|
|
944
|
+
return true;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Forget a workspace: remove its `[workspaces."<slug>"]` table, its keyring
|
|
949
|
+
* entry, and its line in the reference CLI's list — whichever exist. If the
|
|
950
|
+
* removed slug was the default, the default is repointed to a remaining
|
|
951
|
+
* workspace (or cleared). Returns true if anything was removed.
|
|
952
|
+
*/
|
|
953
|
+
export function removeCredential(slug: string): boolean {
|
|
954
|
+
const path = userConfigPath();
|
|
955
|
+
return withUserConfigLock(path, () => {
|
|
956
|
+
let removed = false;
|
|
957
|
+
|
|
958
|
+
const backend = keyring();
|
|
959
|
+
if (backend && backend.delete(slug)) removed = true;
|
|
960
|
+
if (removeFromReferenceCredentials(slug)) removed = true;
|
|
961
|
+
|
|
962
|
+
if (!existsSync(path)) return removed;
|
|
963
|
+
const obj = readUserObject(path);
|
|
964
|
+
const ws = workspacesTable(obj);
|
|
965
|
+
const listed = Boolean(ws[slug]);
|
|
966
|
+
if (listed) {
|
|
967
|
+
delete ws[slug];
|
|
968
|
+
removed = true;
|
|
969
|
+
}
|
|
970
|
+
if (Object.keys(ws).length === 0) delete obj.workspaces;
|
|
971
|
+
|
|
972
|
+
if (asString(obj.default_workspace) === slug) {
|
|
973
|
+
const remaining = obj.workspaces ? Object.keys(obj.workspaces as object) : [];
|
|
974
|
+
if (remaining.length > 0) obj.default_workspace = remaining[0]!;
|
|
975
|
+
else delete obj.default_workspace;
|
|
976
|
+
writeUserObject(path, obj);
|
|
977
|
+
} else if (listed) {
|
|
978
|
+
writeUserObject(path, obj);
|
|
979
|
+
}
|
|
980
|
+
return removed;
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
export interface MigrateResult {
|
|
985
|
+
/** Slugs whose keys moved from the file into the keyring. */
|
|
986
|
+
migrated: string[];
|
|
987
|
+
path: string;
|
|
988
|
+
keyringLabel: string;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Move every plaintext `api_key` in the user config into the OS keyring,
|
|
993
|
+
* leaving `keyring = true` markers behind. All-or-nothing: if one store fails,
|
|
994
|
+
* the entries already written are deleted again and the file is not touched.
|
|
995
|
+
*/
|
|
996
|
+
export function migrateCredentials(): MigrateResult {
|
|
997
|
+
const backend = keyring();
|
|
998
|
+
const path = userConfigPath();
|
|
999
|
+
if (!backend) {
|
|
1000
|
+
throw new CliError(
|
|
1001
|
+
`No system keyring is available on this platform; credentials stay in ${path}.`,
|
|
1002
|
+
"runtime",
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
return withUserConfigLock(path, () => {
|
|
1006
|
+
const obj = readUserObject(path);
|
|
1007
|
+
const ws = workspacesTable(obj);
|
|
1008
|
+
const plaintext = Object.entries(ws).filter(([, t]) => asString(t.api_key));
|
|
1009
|
+
const migrated: string[] = [];
|
|
1010
|
+
try {
|
|
1011
|
+
for (const [slug, table] of plaintext) {
|
|
1012
|
+
backend.set(slug, table.api_key!);
|
|
1013
|
+
migrated.push(slug);
|
|
1014
|
+
}
|
|
1015
|
+
} catch (err) {
|
|
1016
|
+
for (const slug of migrated) {
|
|
1017
|
+
try {
|
|
1018
|
+
backend.delete(slug);
|
|
1019
|
+
} catch {
|
|
1020
|
+
// Best-effort rollback.
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
throw new CliError(
|
|
1024
|
+
`Failed to store the key for workspace '${plaintext[migrated.length]![0]}' in the ${backend.label}: ${(err as Error).message}. Rolled back ${migrated.length} already-written entr${migrated.length === 1 ? "y" : "ies"}; ${path} is unchanged.`,
|
|
1025
|
+
"runtime",
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
if (migrated.length > 0) {
|
|
1029
|
+
for (const [, table] of plaintext) {
|
|
1030
|
+
delete table.api_key;
|
|
1031
|
+
table.keyring = true;
|
|
1032
|
+
}
|
|
1033
|
+
writeUserObject(path, obj);
|
|
1034
|
+
}
|
|
1035
|
+
return { migrated, path, keyringLabel: backend.label };
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
export interface CredentialEntry {
|
|
1040
|
+
/** Workspace slug. */
|
|
1041
|
+
slug: string;
|
|
1042
|
+
isDefault: boolean;
|
|
1043
|
+
/** Where the secret is kept. */
|
|
1044
|
+
storage: CredentialStorage;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/** List configured credentials: one entry per stored workspace. */
|
|
1048
|
+
export function listCredentials(env: NodeJS.ProcessEnv = process.env): CredentialEntry[] {
|
|
1049
|
+
const path = userConfigPath(env);
|
|
1050
|
+
const user = readUserConfig(path);
|
|
1051
|
+
return Object.entries(user.workspaces).map(([slug, entry]) => ({
|
|
1052
|
+
slug,
|
|
1053
|
+
isDefault: user.defaultWorkspace === slug,
|
|
1054
|
+
storage: entry.apiKey ? "file" : "keychain",
|
|
1055
|
+
}));
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** Redact a secret for display: keep the prefix and last 4 chars. */
|
|
1059
|
+
export function redactKey(key: string | undefined): string {
|
|
1060
|
+
if (!key) return "(not set)";
|
|
1061
|
+
if (key.length <= 12) return "••••";
|
|
1062
|
+
const prefix = key.startsWith("lin_api_") ? "lin_api_" : key.slice(0, 4);
|
|
1063
|
+
return `${prefix}••••${key.slice(-4)}`;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// ---------------------------------------------------------------------------
|
|
1067
|
+
// Project config writers (`config init` / `config set`)
|
|
1068
|
+
//
|
|
1069
|
+
// A project file is the user's, hand-edited and committed, so `config set`
|
|
1070
|
+
// edits it textually — one line replaced or appended — and keeps their
|
|
1071
|
+
// comments and layout. The result is parsed back to prove the edit did what it
|
|
1072
|
+
// meant to; only if that check fails does it fall back to a full re-serialize.
|
|
1073
|
+
// ---------------------------------------------------------------------------
|
|
1074
|
+
|
|
1075
|
+
/** The settings `config set` / `config init` will write to a project file. */
|
|
1076
|
+
export const SETTABLE_KEYS = ["team", "workspace", "sort"] as const;
|
|
1077
|
+
export type SettableKey = (typeof SETTABLE_KEYS)[number];
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* The reference CLI's spelling for each key. A file that already uses it keeps
|
|
1081
|
+
* it: `config set team ENG` on a schpet-written `team_id = "TES"` line changes
|
|
1082
|
+
* that line rather than adding a second, competing key.
|
|
1083
|
+
*/
|
|
1084
|
+
const KEY_ALIASES: Record<SettableKey, readonly string[]> = {
|
|
1085
|
+
team: ["team", "team_id"],
|
|
1086
|
+
workspace: ["workspace"],
|
|
1087
|
+
sort: ["sort", "issue_sort"],
|
|
1088
|
+
};
|
|
1089
|
+
|
|
1090
|
+
/** Keys that must never be written to a project file, and why. */
|
|
1091
|
+
const SECRET_KEYS = new Set(["api_key", "workspaces", "default_workspace", "keyring"]);
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Where `config init` writes and `config set` falls back to when no project
|
|
1095
|
+
* config exists yet: `<git root>/.linear.toml` inside a repository (the whole
|
|
1096
|
+
* repo then sees it, since discovery walks up), else `<cwd>/.linear.toml`.
|
|
1097
|
+
*/
|
|
1098
|
+
export function defaultProjectConfigPath(cwd: string = process.cwd()): string {
|
|
1099
|
+
let root: string | undefined;
|
|
1100
|
+
try {
|
|
1101
|
+
root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
1102
|
+
cwd,
|
|
1103
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1104
|
+
encoding: "utf8",
|
|
1105
|
+
}).trim();
|
|
1106
|
+
} catch {
|
|
1107
|
+
// Not a repository.
|
|
1108
|
+
}
|
|
1109
|
+
return join(root || cwd, ".linear.toml");
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/** Refuse a key that would put a secret (or the credential store) in a project file. */
|
|
1113
|
+
export function assertSettableKey(key: string): asserts key is SettableKey {
|
|
1114
|
+
if (SECRET_KEYS.has(key)) {
|
|
1115
|
+
throw new CliError(
|
|
1116
|
+
`'${key}' is not a project setting: secrets and the credential store live only in the user config. Run \`linear auth login\` instead.`,
|
|
1117
|
+
"usage",
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
if (!(SETTABLE_KEYS as readonly string[]).includes(key)) {
|
|
1121
|
+
throw new CliError(
|
|
1122
|
+
`Unknown setting '${key}'. Settable keys: ${SETTABLE_KEYS.join(", ")}.`,
|
|
1123
|
+
"usage",
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/** A TOML basic string: JSON's escapes are a subset of TOML's, so this is exact. */
|
|
1129
|
+
function tomlString(value: string): string {
|
|
1130
|
+
return JSON.stringify(value);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Set one top-level key in a TOML file, creating the file if needed.
|
|
1135
|
+
* Returns the spelling that was written (`team` or `team_id`, say).
|
|
1136
|
+
*/
|
|
1137
|
+
export function setConfigKey(
|
|
1138
|
+
path: string,
|
|
1139
|
+
key: SettableKey,
|
|
1140
|
+
value: string,
|
|
1141
|
+
opts: { mode?: number } = {},
|
|
1142
|
+
): string {
|
|
1143
|
+
const mode = opts.mode ?? 0o644;
|
|
1144
|
+
const mutate = (): string => {
|
|
1145
|
+
const text = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
1146
|
+
// Parse first so a broken file is reported as such rather than edited blind.
|
|
1147
|
+
if (text.trim()) parseTomlFile(path);
|
|
1148
|
+
|
|
1149
|
+
const lines = text.split("\n");
|
|
1150
|
+
// Only the top-level region (before the first table header) is ours to
|
|
1151
|
+
// touch: `api_key = …` inside `[workspaces.x]` must not be mistaken for a
|
|
1152
|
+
// top-level key of the same name.
|
|
1153
|
+
const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
|
|
1154
|
+
const topEnd = firstTable === -1 ? lines.length : firstTable;
|
|
1155
|
+
const spellings = KEY_ALIASES[key];
|
|
1156
|
+
const keyLine = new RegExp(
|
|
1157
|
+
`^\\s*(${spellings.map((s) => s.replace(".", "\\.")).join("|")})\\s*=`,
|
|
1158
|
+
);
|
|
1159
|
+
const idx = lines.findIndex((l, i) => i < topEnd && keyLine.test(l));
|
|
1160
|
+
|
|
1161
|
+
let spelling: string;
|
|
1162
|
+
let next: string[];
|
|
1163
|
+
if (idx !== -1) {
|
|
1164
|
+
spelling = lines[idx]!.match(keyLine)![1]!;
|
|
1165
|
+
// Keep a trailing comment on the line, if any.
|
|
1166
|
+
const comment = lines[idx]!.match(/\s+#.*$/)?.[0] ?? "";
|
|
1167
|
+
next = [...lines];
|
|
1168
|
+
next[idx] = `${spelling} = ${tomlString(value)}${comment}`;
|
|
1169
|
+
} else {
|
|
1170
|
+
spelling = key;
|
|
1171
|
+
next = [...lines];
|
|
1172
|
+
// Drop the trailing empty line a `\n`-terminated file splits into (the
|
|
1173
|
+
// file is re-terminated below), then insert right after the last
|
|
1174
|
+
// non-blank top-level line — before any blank line that separates the
|
|
1175
|
+
// top-level keys from the first table. No alias line exists up there (we
|
|
1176
|
+
// searched), so the insert is unambiguous.
|
|
1177
|
+
if (next[next.length - 1] === "") next.pop();
|
|
1178
|
+
let at = firstTable === -1 ? next.length : firstTable;
|
|
1179
|
+
while (at > 0 && next[at - 1]!.trim() === "") at--;
|
|
1180
|
+
const inserted = [`${spelling} = ${tomlString(value)}`];
|
|
1181
|
+
// A file that opens with a table needs a blank line between us and it.
|
|
1182
|
+
if (at < next.length && /^\s*\[/.test(next[at]!)) inserted.push("");
|
|
1183
|
+
next.splice(at, 0, ...inserted);
|
|
1184
|
+
}
|
|
1185
|
+
let body = next.join("\n");
|
|
1186
|
+
if (!body.endsWith("\n")) body += "\n";
|
|
1187
|
+
|
|
1188
|
+
// Prove the edit: the parsed file must carry exactly this value under this
|
|
1189
|
+
// spelling. If a layout we did not foresee defeats the line edit, fall back
|
|
1190
|
+
// to a full round-trip — correct, if less pretty.
|
|
1191
|
+
let parsed: Record<string, unknown> | undefined;
|
|
1192
|
+
try {
|
|
1193
|
+
parsed = parseToml(body) as Record<string, unknown>;
|
|
1194
|
+
} catch {
|
|
1195
|
+
parsed = undefined;
|
|
1196
|
+
}
|
|
1197
|
+
if (!parsed || parsed[spelling] !== value) {
|
|
1198
|
+
const obj = text.trim() ? parseTomlFile(path) : {};
|
|
1199
|
+
for (const s of spellings) if (s !== spelling) delete obj[s];
|
|
1200
|
+
obj[spelling] = value;
|
|
1201
|
+
body = stringifyToml(obj) + "\n";
|
|
1202
|
+
}
|
|
1203
|
+
writeFileAtomic(path, body, mode);
|
|
1204
|
+
return spelling;
|
|
1205
|
+
};
|
|
1206
|
+
|
|
1207
|
+
// `config set --user` passes 0600 because this file can contain credentials;
|
|
1208
|
+
// serialize that read/line-edit/rename with the credential writers too.
|
|
1209
|
+
return mode === 0o600 ? withUserConfigLock(path, mutate) : mutate();
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Write a fresh project config. Refuses to replace an existing file unless
|
|
1214
|
+
* `force`, since `config set` is the tool for changing one value.
|
|
1215
|
+
*/
|
|
1216
|
+
export function initProjectConfig(
|
|
1217
|
+
path: string,
|
|
1218
|
+
settings: Partial<Record<SettableKey, string>>,
|
|
1219
|
+
opts: { force?: boolean } = {},
|
|
1220
|
+
): void {
|
|
1221
|
+
if (existsSync(path) && !opts.force) {
|
|
1222
|
+
throw new CliError(
|
|
1223
|
+
`${path} already exists. Use \`linear config set <key> <value>\` to change a value, or --force to overwrite it.`,
|
|
1224
|
+
"usage",
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
const lines = [
|
|
1228
|
+
"# linear-sdk-cli project config — non-secret defaults for this repository.",
|
|
1229
|
+
"# The API key never goes here; `linear auth login` stores it for you.",
|
|
1230
|
+
];
|
|
1231
|
+
for (const key of SETTABLE_KEYS) {
|
|
1232
|
+
const value = settings[key];
|
|
1233
|
+
if (value !== undefined) lines.push(`${key} = ${tomlString(value)}`);
|
|
1234
|
+
}
|
|
1235
|
+
writeFileAtomic(path, lines.join("\n") + "\n", 0o644);
|
|
1236
|
+
}
|