linear-sdk-cli 0.1.3 → 0.2.3
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 +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +96 -58
- package/COVERAGE.md +5 -5
- package/MIGRATING.md +71 -69
- package/README.md +115 -90
- package/package.json +6 -7
- package/skills/README.md +15 -0
- package/skills/linear-sdk-cli/SKILL.md +27 -3
- package/skills/linear-sdk-cli/references/api.md +9 -9
- package/skills/linear-sdk-cli/references/auth.md +18 -0
- package/skills/linear-sdk-cli/references/commands.md +2 -2
- package/skills/linear-sdk-cli/references/comment.md +9 -6
- package/skills/linear-sdk-cli/references/config.md +9 -11
- package/skills/linear-sdk-cli/references/issue.md +72 -18
- package/skills/linear-sdk-cli/references/open.md +29 -0
- package/skills/linear-sdk-cli/references/team.md +16 -0
- package/skills/linear-sdk-cli/scripts/generate-docs.ts +6 -2
- package/skills/linear-sdk-cli-maintenance/SKILL.md +97 -0
- package/src/bin/linear.ts +16 -6
- package/src/cli.ts +3 -1
- package/src/commands/agent-session.ts +13 -5
- package/src/commands/api.ts +15 -9
- package/src/commands/attach.ts +15 -4
- package/src/commands/comment.ts +50 -11
- package/src/commands/completion.ts +2 -6
- package/src/commands/cycle.ts +6 -1
- package/src/commands/discover.ts +6 -2
- package/src/commands/document.ts +4 -1
- package/src/commands/initiative.ts +5 -1
- package/src/commands/issue.ts +392 -91
- package/src/commands/meta.ts +139 -31
- package/src/commands/milestone.ts +4 -1
- package/src/commands/notification.ts +15 -6
- package/src/commands/open.ts +84 -0
- package/src/commands/project.ts +17 -4
- package/src/commands/team.ts +13 -0
- package/src/commands/user.ts +2 -2
- package/src/commands/webhook.ts +12 -2
- package/src/config.ts +501 -184
- package/src/context.ts +13 -4
- package/src/git.ts +28 -3
- package/src/lib/body.ts +26 -2
- package/src/lib/errors.ts +33 -8
- package/src/lib/introspect.ts +76 -7
- package/src/lib/keyring.ts +51 -9
- package/src/lib/mentions.ts +49 -0
- package/src/lib/open.ts +32 -0
- package/src/lib/options.ts +108 -12
- package/src/lib/output-shapes.ts +81 -9
- package/src/lib/pagination.ts +50 -17
- package/src/lib/resolve.ts +40 -7
- package/src/lib/shape.ts +16 -12
- package/src/lib/upload.ts +3 -1
- package/src/output/color.ts +52 -0
- package/src/output/format.ts +96 -18
- package/src/output/markdown.ts +133 -0
- package/src/output/pager.ts +131 -0
- package/src/output/sanitize.ts +1 -1
- package/src/output/table.ts +7 -7
- package/src/services/agent-session.ts +11 -3
- package/src/services/attachment.ts +14 -5
- package/src/services/comment.ts +37 -9
- package/src/services/cycle.ts +11 -4
- package/src/services/document.ts +29 -10
- package/src/services/favorite.ts +15 -9
- package/src/services/initiative-update.ts +16 -15
- package/src/services/initiative.ts +15 -10
- package/src/services/issue.ts +263 -19
- package/src/services/label.ts +12 -2
- package/src/services/milestone.ts +8 -5
- package/src/services/notification.ts +16 -2
- package/src/services/organization.ts +23 -18
- package/src/services/project.ts +55 -34
- package/src/services/roadmap.ts +7 -6
- package/src/services/state.ts +9 -3
- package/src/services/team.ts +67 -24
- package/src/services/user.ts +2 -2
- package/src/services/webhook.ts +28 -17
package/src/config.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Configuration resolution.
|
|
3
3
|
*
|
|
4
4
|
* Two tiers with different trust boundaries:
|
|
5
|
-
* - Non-secret settings (team, workspace, sort
|
|
5
|
+
* - Non-secret settings (team, workspace, sort): flag > env > project
|
|
6
6
|
* config > user config (`~/.config/linear/config.toml`) > the reference
|
|
7
7
|
* CLI's global config (`~/.config/linear/linear.toml`, read-only).
|
|
8
8
|
* The project config is the first file found walking cwd → filesystem
|
|
@@ -40,8 +40,10 @@ import {
|
|
|
40
40
|
chmodSync,
|
|
41
41
|
openSync,
|
|
42
42
|
closeSync,
|
|
43
|
+
fstatSync,
|
|
43
44
|
fsyncSync,
|
|
44
45
|
renameSync,
|
|
46
|
+
statSync,
|
|
45
47
|
unlinkSync,
|
|
46
48
|
} from "node:fs";
|
|
47
49
|
import { execFileSync } from "node:child_process";
|
|
@@ -53,7 +55,6 @@ export interface RawSettings {
|
|
|
53
55
|
team?: string;
|
|
54
56
|
workspace?: string;
|
|
55
57
|
sort?: string;
|
|
56
|
-
vcs?: string;
|
|
57
58
|
apiKey?: string;
|
|
58
59
|
}
|
|
59
60
|
|
|
@@ -67,14 +68,16 @@ export type ConfigSource = "flag" | "env" | "project" | "user" | "global" | "key
|
|
|
67
68
|
/** Where a stored workspace credential's secret lives. */
|
|
68
69
|
export type CredentialStorage = "file" | "keychain";
|
|
69
70
|
|
|
70
|
-
/** A setting's provenance: which tier, and (for a file) which file. */
|
|
71
|
+
/** A setting's provenance: which tier, and (for a file) which file/key. */
|
|
71
72
|
export interface SettingOrigin {
|
|
72
73
|
source: ConfigSource;
|
|
73
74
|
path?: string;
|
|
75
|
+
/** Exact spelling found in the file (`issue_sort`, not just canonical `sort`). */
|
|
76
|
+
key?: string;
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
/** The non-secret settings, each with its provenance. */
|
|
77
|
-
export type SettingOrigins = Record<"team" | "workspace" | "sort"
|
|
80
|
+
export type SettingOrigins = Record<"team" | "workspace" | "sort", SettingOrigin>;
|
|
78
81
|
|
|
79
82
|
export interface ResolvedConfig {
|
|
80
83
|
apiKey?: string;
|
|
@@ -95,7 +98,6 @@ export interface ResolvedConfig {
|
|
|
95
98
|
sort: string;
|
|
96
99
|
/** Where `sort` came from, so an invalid value can be blamed precisely. */
|
|
97
100
|
sortSource: ConfigSource;
|
|
98
|
-
vcs: string;
|
|
99
101
|
/** Provenance of every non-secret setting (`linear config` shows it). */
|
|
100
102
|
origins: SettingOrigins;
|
|
101
103
|
/** Absolute path of the user config file (may not exist yet). */
|
|
@@ -137,7 +139,11 @@ export function globalConfigPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
|
137
139
|
* directory — the reference CLI's order, so a repo that has more than one
|
|
138
140
|
* resolves the same way under both tools.
|
|
139
141
|
*/
|
|
140
|
-
export const PROJECT_CONFIG_NAMES = [
|
|
142
|
+
export const PROJECT_CONFIG_NAMES = [
|
|
143
|
+
"linear.toml",
|
|
144
|
+
".linear.toml",
|
|
145
|
+
join(".config", "linear.toml"),
|
|
146
|
+
] as const;
|
|
141
147
|
|
|
142
148
|
/**
|
|
143
149
|
* The reference CLI's credentials file, a sibling of our config. Its keyring
|
|
@@ -160,12 +166,22 @@ interface WorkspaceEntry {
|
|
|
160
166
|
interface UserConfig {
|
|
161
167
|
/** Top-level non-secret settings. */
|
|
162
168
|
settings: RawSettings;
|
|
169
|
+
/** Exact top-level key spelling that supplied each non-secret setting. */
|
|
170
|
+
settingKeys: SettingKeys;
|
|
163
171
|
/** default_workspace, if set (ours, else the reference CLI's `default`). */
|
|
164
172
|
defaultWorkspace?: string;
|
|
165
173
|
/** slug → entry from `[workspaces."<slug>"]`, plus the reference CLI's list. */
|
|
166
174
|
workspaces: Record<string, WorkspaceEntry>;
|
|
167
175
|
}
|
|
168
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
|
+
|
|
169
185
|
function asString(v: unknown): string | undefined {
|
|
170
186
|
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
171
187
|
}
|
|
@@ -177,7 +193,7 @@ function asString(v: unknown): string | undefined {
|
|
|
177
193
|
* spelled as escapes so no literal control byte lives in this source.
|
|
178
194
|
*/
|
|
179
195
|
const CONTROL_CHARS = new RegExp(
|
|
180
|
-
//
|
|
196
|
+
// oxlint-disable-next-line no-control-regex
|
|
181
197
|
"[\\u0000-\\u001F\\u007F-\\u009F\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u2069]",
|
|
182
198
|
"g",
|
|
183
199
|
);
|
|
@@ -231,14 +247,31 @@ function parseTomlFile(path: string): Record<string, unknown> {
|
|
|
231
247
|
* Read flat NON-SECRET settings from a TOML file. Used for project
|
|
232
248
|
* `.linear.toml`; the API key is intentionally NOT read here (trust boundary).
|
|
233
249
|
*/
|
|
234
|
-
function
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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 };
|
|
241
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);
|
|
242
275
|
}
|
|
243
276
|
|
|
244
277
|
/**
|
|
@@ -247,12 +280,17 @@ function readTomlFile(path: string): RawSettings {
|
|
|
247
280
|
* listing those slugs without reading their keys would only produce a
|
|
248
281
|
* "no stored credential" error for a workspace the user can see in the file.
|
|
249
282
|
*/
|
|
250
|
-
function readReferenceCredentials(path: string): {
|
|
283
|
+
function readReferenceCredentials(path: string): {
|
|
284
|
+
workspaces: string[];
|
|
285
|
+
defaultWorkspace?: string;
|
|
286
|
+
} {
|
|
251
287
|
if (!existsSync(path)) return { workspaces: [] };
|
|
252
288
|
const parsed = parseTomlFile(path);
|
|
253
289
|
const list = parsed.workspaces;
|
|
254
290
|
if (!Array.isArray(list)) return { workspaces: [] };
|
|
255
|
-
const workspaces = [
|
|
291
|
+
const workspaces = [
|
|
292
|
+
...new Set(list.filter((v): v is string => typeof v === "string" && v.length > 0)),
|
|
293
|
+
];
|
|
256
294
|
const def = asString(parsed.default);
|
|
257
295
|
return {
|
|
258
296
|
workspaces,
|
|
@@ -265,6 +303,7 @@ function readReferenceCredentials(path: string): { workspaces: string[]; default
|
|
|
265
303
|
function readUserConfig(path: string): UserConfig {
|
|
266
304
|
const workspaces: Record<string, WorkspaceEntry> = {};
|
|
267
305
|
const parsed = existsSync(path) ? parseTomlFile(path) : {};
|
|
306
|
+
const fileSettings = settingsFromParsed(parsed);
|
|
268
307
|
const wsTable = parsed.workspaces;
|
|
269
308
|
if (wsTable && typeof wsTable === "object") {
|
|
270
309
|
for (const [slug, val] of Object.entries(wsTable as Record<string, unknown>)) {
|
|
@@ -280,12 +319,8 @@ function readUserConfig(path: string): UserConfig {
|
|
|
280
319
|
const reference = readReferenceCredentials(join(dirname(path), "credentials.toml"));
|
|
281
320
|
for (const slug of reference.workspaces) workspaces[slug] ??= {};
|
|
282
321
|
return {
|
|
283
|
-
settings:
|
|
284
|
-
|
|
285
|
-
workspace: asString(parsed.workspace),
|
|
286
|
-
sort: asString(parsed.sort ?? parsed.issue_sort),
|
|
287
|
-
vcs: asString(parsed.vcs),
|
|
288
|
-
},
|
|
322
|
+
settings: fileSettings.settings,
|
|
323
|
+
settingKeys: fileSettings.keys,
|
|
289
324
|
defaultWorkspace: asString(parsed.default_workspace) ?? reference.defaultWorkspace,
|
|
290
325
|
workspaces,
|
|
291
326
|
};
|
|
@@ -353,7 +388,6 @@ function fromEnv(env: NodeJS.ProcessEnv): RawSettings {
|
|
|
353
388
|
team: env.LINEAR_TEAM || env.LINEAR_TEAM_ID,
|
|
354
389
|
workspace: env.LINEAR_WORKSPACE,
|
|
355
390
|
sort: env.LINEAR_ISSUE_SORT,
|
|
356
|
-
vcs: env.LINEAR_VCS,
|
|
357
391
|
};
|
|
358
392
|
}
|
|
359
393
|
|
|
@@ -366,12 +400,12 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
366
400
|
const userPath = userConfigPath(env);
|
|
367
401
|
const user = readUserConfig(userPath);
|
|
368
402
|
const projectPath = findProjectConfig(cwd);
|
|
369
|
-
const
|
|
403
|
+
const project = projectPath ? readTomlFile(projectPath) : { settings: {}, keys: {} };
|
|
370
404
|
// The reference CLI's global file: same non-secret reader as a project file,
|
|
371
405
|
// so its `api_key` (it does allow one there) is never picked up.
|
|
372
406
|
const globalCandidate = globalConfigPath(env);
|
|
373
407
|
const globalPath = existsSync(globalCandidate) ? globalCandidate : undefined;
|
|
374
|
-
const
|
|
408
|
+
const global = globalPath ? readTomlFile(globalPath) : { settings: {}, keys: {} };
|
|
375
409
|
|
|
376
410
|
// ----- API key resolution (strict trust boundary) -----------------------
|
|
377
411
|
// Resolution is TOTAL: selection problems are stashed in `apiKeyError` rather
|
|
@@ -395,7 +429,10 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
395
429
|
// already-stored credential by its non-secret slug, but cannot supply a
|
|
396
430
|
// key: lookupCredential only reads the user config and OS keyring.
|
|
397
431
|
const selected =
|
|
398
|
-
flags.workspace ??
|
|
432
|
+
flags.workspace ??
|
|
433
|
+
env.LINEAR_WORKSPACE ??
|
|
434
|
+
project.settings.workspace ??
|
|
435
|
+
user.defaultWorkspace;
|
|
399
436
|
|
|
400
437
|
// Apply a lookup result for the slug the selection settled on. An
|
|
401
438
|
// explicitly named slug is probed even when no file lists it — the keyring
|
|
@@ -438,27 +475,30 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
438
475
|
|
|
439
476
|
// ----- Non-secret settings (project + global config MAY participate) -----
|
|
440
477
|
// flag > env > project file > our user file > the reference CLI's global file.
|
|
441
|
-
const tiers: Array<[ConfigSource, RawSettings, string | undefined]> = [
|
|
442
|
-
["flag", flags, undefined],
|
|
443
|
-
["env", envSettings, undefined],
|
|
444
|
-
["project",
|
|
445
|
-
["user", user.settings, userPath],
|
|
446
|
-
["global",
|
|
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],
|
|
447
484
|
];
|
|
448
|
-
const pick = <K extends "team" | "workspace" | "sort"
|
|
485
|
+
const pick = <K extends "team" | "workspace" | "sort">(key: K): string | undefined =>
|
|
449
486
|
tiers.find(([, settings]) => settings[key] !== undefined)?.[1][key];
|
|
450
487
|
|
|
451
488
|
/** Which tier `pick` took the value from, and which file if it was one. */
|
|
452
|
-
const originOf = (key: "team" | "workspace" | "sort"
|
|
489
|
+
const originOf = (key: "team" | "workspace" | "sort"): SettingOrigin => {
|
|
453
490
|
const hit = tiers.find(([, settings]) => settings[key] !== undefined);
|
|
454
|
-
|
|
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;
|
|
455
496
|
};
|
|
456
497
|
|
|
457
498
|
const origins: SettingOrigins = {
|
|
458
499
|
team: originOf("team"),
|
|
459
500
|
workspace: originOf("workspace"),
|
|
460
501
|
sort: originOf("sort"),
|
|
461
|
-
vcs: originOf("vcs"),
|
|
462
502
|
};
|
|
463
503
|
|
|
464
504
|
return {
|
|
@@ -472,7 +512,6 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
472
512
|
workspace: pick("workspace"),
|
|
473
513
|
sort: pick("sort") ?? "priority",
|
|
474
514
|
sortSource: origins.sort.source,
|
|
475
|
-
vcs: pick("vcs") ?? "git",
|
|
476
515
|
origins,
|
|
477
516
|
userConfigPath: userPath,
|
|
478
517
|
projectConfigPath: projectPath,
|
|
@@ -495,6 +534,184 @@ function readUserObject(path: string): Record<string, unknown> {
|
|
|
495
534
|
return parseTomlFile(path);
|
|
496
535
|
}
|
|
497
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
|
+
|
|
498
715
|
/**
|
|
499
716
|
* Persist text ATOMICALLY, with the given perms.
|
|
500
717
|
*
|
|
@@ -560,6 +777,91 @@ export interface WriteCredentialResult {
|
|
|
560
777
|
keyringLabel?: string;
|
|
561
778
|
}
|
|
562
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
|
+
|
|
563
865
|
/**
|
|
564
866
|
* Store a credential for `slug`. By default the secret goes to the OS keyring
|
|
565
867
|
* (service `linear-cli`, account = slug) and the file only records
|
|
@@ -576,47 +878,51 @@ export function writeCredential(
|
|
|
576
878
|
opts: { plaintext?: boolean } = {},
|
|
577
879
|
): WriteCredentialResult {
|
|
578
880
|
const path = userConfigPath();
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
+
});
|
|
604
908
|
}
|
|
605
909
|
|
|
606
910
|
/** Set `default_workspace`. Errors if that workspace is not configured anywhere. */
|
|
607
911
|
export function setDefaultWorkspace(slug: string): string {
|
|
608
912
|
const path = userConfigPath();
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
+
});
|
|
620
926
|
}
|
|
621
927
|
|
|
622
928
|
/**
|
|
@@ -646,31 +952,33 @@ function removeFromReferenceCredentials(slug: string): boolean {
|
|
|
646
952
|
*/
|
|
647
953
|
export function removeCredential(slug: string): boolean {
|
|
648
954
|
const path = userConfigPath();
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
+
});
|
|
674
982
|
}
|
|
675
983
|
|
|
676
984
|
export interface MigrateResult {
|
|
@@ -694,36 +1002,38 @@ export function migrateCredentials(): MigrateResult {
|
|
|
694
1002
|
"runtime",
|
|
695
1003
|
);
|
|
696
1004
|
}
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
+
}
|
|
712
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
|
+
);
|
|
713
1027
|
}
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
for (const [, table] of plaintext) {
|
|
721
|
-
delete table.api_key;
|
|
722
|
-
table.keyring = true;
|
|
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);
|
|
723
1034
|
}
|
|
724
|
-
|
|
725
|
-
}
|
|
726
|
-
return { migrated, path, keyringLabel: backend.label };
|
|
1035
|
+
return { migrated, path, keyringLabel: backend.label };
|
|
1036
|
+
});
|
|
727
1037
|
}
|
|
728
1038
|
|
|
729
1039
|
export interface CredentialEntry {
|
|
@@ -763,7 +1073,7 @@ export function redactKey(key: string | undefined): string {
|
|
|
763
1073
|
// ---------------------------------------------------------------------------
|
|
764
1074
|
|
|
765
1075
|
/** The settings `config set` / `config init` will write to a project file. */
|
|
766
|
-
export const SETTABLE_KEYS = ["team", "workspace", "sort"
|
|
1076
|
+
export const SETTABLE_KEYS = ["team", "workspace", "sort"] as const;
|
|
767
1077
|
export type SettableKey = (typeof SETTABLE_KEYS)[number];
|
|
768
1078
|
|
|
769
1079
|
/**
|
|
@@ -775,7 +1085,6 @@ const KEY_ALIASES: Record<SettableKey, readonly string[]> = {
|
|
|
775
1085
|
team: ["team", "team_id"],
|
|
776
1086
|
workspace: ["workspace"],
|
|
777
1087
|
sort: ["sort", "issue_sort"],
|
|
778
|
-
vcs: ["vcs"],
|
|
779
1088
|
};
|
|
780
1089
|
|
|
781
1090
|
/** Keys that must never be written to a project file, and why. */
|
|
@@ -832,64 +1141,72 @@ export function setConfigKey(
|
|
|
832
1141
|
opts: { mode?: number } = {},
|
|
833
1142
|
): string {
|
|
834
1143
|
const mode = opts.mode ?? 0o644;
|
|
835
|
-
const
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
spelling
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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";
|
|
875
1187
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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();
|
|
893
1210
|
}
|
|
894
1211
|
|
|
895
1212
|
/**
|