linear-sdk-cli 0.1.0 → 0.1.2
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/CHANGELOG.md +46 -46
- package/COVERAGE.md +5 -5
- package/MIGRATING.md +69 -71
- package/README.md +88 -96
- package/package.json +8 -9
- package/skills/linear-sdk-cli/SKILL.md +3 -13
- package/skills/linear-sdk-cli/references/api.md +9 -9
- package/skills/linear-sdk-cli/references/auth.md +0 -18
- package/skills/linear-sdk-cli/references/commands.md +2 -2
- package/skills/linear-sdk-cli/references/config.md +11 -9
- package/skills/linear-sdk-cli/references/issue.md +12 -63
- package/skills/linear-sdk-cli/references/team.md +0 -16
- package/skills/linear-sdk-cli/scripts/generate-docs.ts +2 -6
- package/src/bin/linear.ts +6 -16
- package/src/cli.ts +1 -3
- package/src/commands/agent-session.ts +5 -13
- package/src/commands/api.ts +9 -15
- package/src/commands/attach.ts +4 -15
- package/src/commands/comment.ts +2 -6
- package/src/commands/completion.ts +6 -2
- package/src/commands/cycle.ts +1 -6
- package/src/commands/discover.ts +2 -6
- package/src/commands/document.ts +1 -4
- package/src/commands/initiative.ts +1 -5
- package/src/commands/issue.ts +88 -376
- package/src/commands/meta.ts +31 -139
- package/src/commands/milestone.ts +1 -4
- package/src/commands/notification.ts +6 -15
- package/src/commands/project.ts +4 -17
- package/src/commands/team.ts +0 -13
- package/src/commands/webhook.ts +2 -12
- package/src/config.ts +183 -500
- package/src/context.ts +4 -13
- package/src/git.ts +3 -28
- package/src/lib/body.ts +2 -26
- package/src/lib/errors.ts +7 -32
- package/src/lib/introspect.ts +7 -76
- package/src/lib/keyring.ts +9 -51
- package/src/lib/options.ts +12 -108
- package/src/lib/output-shapes.ts +9 -81
- package/src/lib/pagination.ts +17 -50
- package/src/lib/resolve.ts +7 -40
- package/src/lib/shape.ts +12 -16
- package/src/lib/upload.ts +1 -3
- package/src/output/format.ts +18 -96
- package/src/output/table.ts +7 -7
- package/src/services/agent-session.ts +3 -11
- package/src/services/attachment.ts +5 -14
- package/src/services/comment.ts +4 -12
- package/src/services/cycle.ts +4 -11
- package/src/services/document.ts +10 -29
- package/src/services/favorite.ts +9 -15
- package/src/services/initiative-update.ts +15 -16
- package/src/services/initiative.ts +10 -15
- package/src/services/issue.ts +18 -262
- package/src/services/label.ts +2 -12
- package/src/services/milestone.ts +5 -8
- package/src/services/notification.ts +2 -16
- package/src/services/organization.ts +18 -23
- package/src/services/project.ts +34 -55
- package/src/services/roadmap.ts +6 -7
- package/src/services/state.ts +3 -9
- package/src/services/team.ts +24 -67
- package/src/services/user.ts +2 -2
- package/src/services/webhook.ts +17 -28
- package/skills/linear-sdk-cli/references/open.md +0 -29
- package/src/commands/open.ts +0 -84
- package/src/lib/open.ts +0 -32
- package/src/output/color.ts +0 -52
- package/src/output/markdown.ts +0 -133
- package/src/output/pager.ts +0 -131
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): flag > env > project
|
|
5
|
+
* - Non-secret settings (team, workspace, sort, vcs): 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,10 +40,8 @@ import {
|
|
|
40
40
|
chmodSync,
|
|
41
41
|
openSync,
|
|
42
42
|
closeSync,
|
|
43
|
-
fstatSync,
|
|
44
43
|
fsyncSync,
|
|
45
44
|
renameSync,
|
|
46
|
-
statSync,
|
|
47
45
|
unlinkSync,
|
|
48
46
|
} from "node:fs";
|
|
49
47
|
import { execFileSync } from "node:child_process";
|
|
@@ -55,6 +53,7 @@ export interface RawSettings {
|
|
|
55
53
|
team?: string;
|
|
56
54
|
workspace?: string;
|
|
57
55
|
sort?: string;
|
|
56
|
+
vcs?: string;
|
|
58
57
|
apiKey?: string;
|
|
59
58
|
}
|
|
60
59
|
|
|
@@ -68,16 +67,14 @@ export type ConfigSource = "flag" | "env" | "project" | "user" | "global" | "key
|
|
|
68
67
|
/** Where a stored workspace credential's secret lives. */
|
|
69
68
|
export type CredentialStorage = "file" | "keychain";
|
|
70
69
|
|
|
71
|
-
/** A setting's provenance: which tier, and (for a file) which file
|
|
70
|
+
/** A setting's provenance: which tier, and (for a file) which file. */
|
|
72
71
|
export interface SettingOrigin {
|
|
73
72
|
source: ConfigSource;
|
|
74
73
|
path?: string;
|
|
75
|
-
/** Exact spelling found in the file (`issue_sort`, not just canonical `sort`). */
|
|
76
|
-
key?: string;
|
|
77
74
|
}
|
|
78
75
|
|
|
79
76
|
/** The non-secret settings, each with its provenance. */
|
|
80
|
-
export type SettingOrigins = Record<"team" | "workspace" | "sort", SettingOrigin>;
|
|
77
|
+
export type SettingOrigins = Record<"team" | "workspace" | "sort" | "vcs", SettingOrigin>;
|
|
81
78
|
|
|
82
79
|
export interface ResolvedConfig {
|
|
83
80
|
apiKey?: string;
|
|
@@ -98,6 +95,7 @@ export interface ResolvedConfig {
|
|
|
98
95
|
sort: string;
|
|
99
96
|
/** Where `sort` came from, so an invalid value can be blamed precisely. */
|
|
100
97
|
sortSource: ConfigSource;
|
|
98
|
+
vcs: string;
|
|
101
99
|
/** Provenance of every non-secret setting (`linear config` shows it). */
|
|
102
100
|
origins: SettingOrigins;
|
|
103
101
|
/** Absolute path of the user config file (may not exist yet). */
|
|
@@ -139,11 +137,7 @@ export function globalConfigPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
|
139
137
|
* directory — the reference CLI's order, so a repo that has more than one
|
|
140
138
|
* resolves the same way under both tools.
|
|
141
139
|
*/
|
|
142
|
-
export const PROJECT_CONFIG_NAMES = [
|
|
143
|
-
"linear.toml",
|
|
144
|
-
".linear.toml",
|
|
145
|
-
join(".config", "linear.toml"),
|
|
146
|
-
] as const;
|
|
140
|
+
export const PROJECT_CONFIG_NAMES = ["linear.toml", ".linear.toml", join(".config", "linear.toml")] as const;
|
|
147
141
|
|
|
148
142
|
/**
|
|
149
143
|
* The reference CLI's credentials file, a sibling of our config. Its keyring
|
|
@@ -166,22 +160,12 @@ interface WorkspaceEntry {
|
|
|
166
160
|
interface UserConfig {
|
|
167
161
|
/** Top-level non-secret settings. */
|
|
168
162
|
settings: RawSettings;
|
|
169
|
-
/** Exact top-level key spelling that supplied each non-secret setting. */
|
|
170
|
-
settingKeys: SettingKeys;
|
|
171
163
|
/** default_workspace, if set (ours, else the reference CLI's `default`). */
|
|
172
164
|
defaultWorkspace?: string;
|
|
173
165
|
/** slug → entry from `[workspaces."<slug>"]`, plus the reference CLI's list. */
|
|
174
166
|
workspaces: Record<string, WorkspaceEntry>;
|
|
175
167
|
}
|
|
176
168
|
|
|
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
169
|
function asString(v: unknown): string | undefined {
|
|
186
170
|
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
187
171
|
}
|
|
@@ -247,31 +231,14 @@ function parseTomlFile(path: string): Record<string, unknown> {
|
|
|
247
231
|
* Read flat NON-SECRET settings from a TOML file. Used for project
|
|
248
232
|
* `.linear.toml`; the API key is intentionally NOT read here (trust boundary).
|
|
249
233
|
*/
|
|
250
|
-
function
|
|
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 {
|
|
234
|
+
function readTomlFile(path: string): RawSettings {
|
|
273
235
|
const parsed = parseTomlFile(path);
|
|
274
|
-
return
|
|
236
|
+
return {
|
|
237
|
+
team: asString(parsed.team ?? parsed.team_id),
|
|
238
|
+
workspace: asString(parsed.workspace),
|
|
239
|
+
sort: asString(parsed.sort ?? parsed.issue_sort),
|
|
240
|
+
vcs: asString(parsed.vcs),
|
|
241
|
+
};
|
|
275
242
|
}
|
|
276
243
|
|
|
277
244
|
/**
|
|
@@ -280,17 +247,12 @@ function readTomlFile(path: string): FileSettings {
|
|
|
280
247
|
* listing those slugs without reading their keys would only produce a
|
|
281
248
|
* "no stored credential" error for a workspace the user can see in the file.
|
|
282
249
|
*/
|
|
283
|
-
function readReferenceCredentials(path: string): {
|
|
284
|
-
workspaces: string[];
|
|
285
|
-
defaultWorkspace?: string;
|
|
286
|
-
} {
|
|
250
|
+
function readReferenceCredentials(path: string): { workspaces: string[]; defaultWorkspace?: string } {
|
|
287
251
|
if (!existsSync(path)) return { workspaces: [] };
|
|
288
252
|
const parsed = parseTomlFile(path);
|
|
289
253
|
const list = parsed.workspaces;
|
|
290
254
|
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
|
-
];
|
|
255
|
+
const workspaces = [...new Set(list.filter((v): v is string => typeof v === "string" && v.length > 0))];
|
|
294
256
|
const def = asString(parsed.default);
|
|
295
257
|
return {
|
|
296
258
|
workspaces,
|
|
@@ -303,7 +265,6 @@ function readReferenceCredentials(path: string): {
|
|
|
303
265
|
function readUserConfig(path: string): UserConfig {
|
|
304
266
|
const workspaces: Record<string, WorkspaceEntry> = {};
|
|
305
267
|
const parsed = existsSync(path) ? parseTomlFile(path) : {};
|
|
306
|
-
const fileSettings = settingsFromParsed(parsed);
|
|
307
268
|
const wsTable = parsed.workspaces;
|
|
308
269
|
if (wsTable && typeof wsTable === "object") {
|
|
309
270
|
for (const [slug, val] of Object.entries(wsTable as Record<string, unknown>)) {
|
|
@@ -319,8 +280,12 @@ function readUserConfig(path: string): UserConfig {
|
|
|
319
280
|
const reference = readReferenceCredentials(join(dirname(path), "credentials.toml"));
|
|
320
281
|
for (const slug of reference.workspaces) workspaces[slug] ??= {};
|
|
321
282
|
return {
|
|
322
|
-
settings:
|
|
323
|
-
|
|
283
|
+
settings: {
|
|
284
|
+
team: asString(parsed.team ?? parsed.team_id),
|
|
285
|
+
workspace: asString(parsed.workspace),
|
|
286
|
+
sort: asString(parsed.sort ?? parsed.issue_sort),
|
|
287
|
+
vcs: asString(parsed.vcs),
|
|
288
|
+
},
|
|
324
289
|
defaultWorkspace: asString(parsed.default_workspace) ?? reference.defaultWorkspace,
|
|
325
290
|
workspaces,
|
|
326
291
|
};
|
|
@@ -388,6 +353,7 @@ function fromEnv(env: NodeJS.ProcessEnv): RawSettings {
|
|
|
388
353
|
team: env.LINEAR_TEAM || env.LINEAR_TEAM_ID,
|
|
389
354
|
workspace: env.LINEAR_WORKSPACE,
|
|
390
355
|
sort: env.LINEAR_ISSUE_SORT,
|
|
356
|
+
vcs: env.LINEAR_VCS,
|
|
391
357
|
};
|
|
392
358
|
}
|
|
393
359
|
|
|
@@ -400,12 +366,12 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
400
366
|
const userPath = userConfigPath(env);
|
|
401
367
|
const user = readUserConfig(userPath);
|
|
402
368
|
const projectPath = findProjectConfig(cwd);
|
|
403
|
-
const
|
|
369
|
+
const projectSettings = projectPath ? readTomlFile(projectPath) : {};
|
|
404
370
|
// The reference CLI's global file: same non-secret reader as a project file,
|
|
405
371
|
// so its `api_key` (it does allow one there) is never picked up.
|
|
406
372
|
const globalCandidate = globalConfigPath(env);
|
|
407
373
|
const globalPath = existsSync(globalCandidate) ? globalCandidate : undefined;
|
|
408
|
-
const
|
|
374
|
+
const globalSettings = globalPath ? readTomlFile(globalPath) : {};
|
|
409
375
|
|
|
410
376
|
// ----- API key resolution (strict trust boundary) -----------------------
|
|
411
377
|
// Resolution is TOTAL: selection problems are stashed in `apiKeyError` rather
|
|
@@ -429,10 +395,7 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
429
395
|
// already-stored credential by its non-secret slug, but cannot supply a
|
|
430
396
|
// key: lookupCredential only reads the user config and OS keyring.
|
|
431
397
|
const selected =
|
|
432
|
-
flags.workspace ??
|
|
433
|
-
env.LINEAR_WORKSPACE ??
|
|
434
|
-
project.settings.workspace ??
|
|
435
|
-
user.defaultWorkspace;
|
|
398
|
+
flags.workspace ?? env.LINEAR_WORKSPACE ?? projectSettings.workspace ?? user.defaultWorkspace;
|
|
436
399
|
|
|
437
400
|
// Apply a lookup result for the slug the selection settled on. An
|
|
438
401
|
// explicitly named slug is probed even when no file lists it — the keyring
|
|
@@ -475,30 +438,27 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
475
438
|
|
|
476
439
|
// ----- Non-secret settings (project + global config MAY participate) -----
|
|
477
440
|
// flag > env > project file > our user file > the reference CLI's global file.
|
|
478
|
-
const tiers: Array<[ConfigSource, RawSettings, string | undefined
|
|
479
|
-
["flag", flags, undefined
|
|
480
|
-
["env", envSettings, undefined
|
|
481
|
-
["project",
|
|
482
|
-
["user", user.settings, userPath
|
|
483
|
-
["global",
|
|
441
|
+
const tiers: Array<[ConfigSource, RawSettings, string | undefined]> = [
|
|
442
|
+
["flag", flags, undefined],
|
|
443
|
+
["env", envSettings, undefined],
|
|
444
|
+
["project", projectSettings, projectPath],
|
|
445
|
+
["user", user.settings, userPath],
|
|
446
|
+
["global", globalSettings, globalPath],
|
|
484
447
|
];
|
|
485
|
-
const pick = <K extends "team" | "workspace" | "sort">(key: K): string | undefined =>
|
|
448
|
+
const pick = <K extends "team" | "workspace" | "sort" | "vcs">(key: K): string | undefined =>
|
|
486
449
|
tiers.find(([, settings]) => settings[key] !== undefined)?.[1][key];
|
|
487
450
|
|
|
488
451
|
/** Which tier `pick` took the value from, and which file if it was one. */
|
|
489
|
-
const originOf = (key: "team" | "workspace" | "sort"): SettingOrigin => {
|
|
452
|
+
const originOf = (key: "team" | "workspace" | "sort" | "vcs"): SettingOrigin => {
|
|
490
453
|
const hit = tiers.find(([, settings]) => settings[key] !== undefined);
|
|
491
|
-
|
|
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;
|
|
454
|
+
return hit ? { source: hit[0], path: hit[2] } : { source: "none" };
|
|
496
455
|
};
|
|
497
456
|
|
|
498
457
|
const origins: SettingOrigins = {
|
|
499
458
|
team: originOf("team"),
|
|
500
459
|
workspace: originOf("workspace"),
|
|
501
460
|
sort: originOf("sort"),
|
|
461
|
+
vcs: originOf("vcs"),
|
|
502
462
|
};
|
|
503
463
|
|
|
504
464
|
return {
|
|
@@ -512,6 +472,7 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
512
472
|
workspace: pick("workspace"),
|
|
513
473
|
sort: pick("sort") ?? "priority",
|
|
514
474
|
sortSource: origins.sort.source,
|
|
475
|
+
vcs: pick("vcs") ?? "git",
|
|
515
476
|
origins,
|
|
516
477
|
userConfigPath: userPath,
|
|
517
478
|
projectConfigPath: projectPath,
|
|
@@ -534,184 +495,6 @@ function readUserObject(path: string): Record<string, unknown> {
|
|
|
534
495
|
return parseTomlFile(path);
|
|
535
496
|
}
|
|
536
497
|
|
|
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
498
|
/**
|
|
716
499
|
* Persist text ATOMICALLY, with the given perms.
|
|
717
500
|
*
|
|
@@ -777,91 +560,6 @@ export interface WriteCredentialResult {
|
|
|
777
560
|
keyringLabel?: string;
|
|
778
561
|
}
|
|
779
562
|
|
|
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
563
|
/**
|
|
866
564
|
* Store a credential for `slug`. By default the secret goes to the OS keyring
|
|
867
565
|
* (service `linear-cli`, account = slug) and the file only records
|
|
@@ -878,51 +576,47 @@ export function writeCredential(
|
|
|
878
576
|
opts: { plaintext?: boolean } = {},
|
|
879
577
|
): WriteCredentialResult {
|
|
880
578
|
const path = userConfigPath();
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
if
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
if
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
return { path, storage, keyringLabel: backend?.label };
|
|
907
|
-
});
|
|
579
|
+
const backend = opts.plaintext ? null : keyring();
|
|
580
|
+
const obj = readUserObject(path);
|
|
581
|
+
const ws = workspacesTable(obj);
|
|
582
|
+
const table: WorkspaceTable = { ...(ws[slug] ?? {}) };
|
|
583
|
+
let storage: CredentialStorage;
|
|
584
|
+
if (backend) {
|
|
585
|
+
// Keyring first: if it refuses, the file is untouched and the error is loud.
|
|
586
|
+
backend.set(slug, apiKey);
|
|
587
|
+
delete table.api_key;
|
|
588
|
+
table.keyring = true;
|
|
589
|
+
storage = "keychain";
|
|
590
|
+
} else {
|
|
591
|
+
delete table.keyring;
|
|
592
|
+
table.api_key = apiKey;
|
|
593
|
+
storage = "file";
|
|
594
|
+
}
|
|
595
|
+
ws[slug] = table;
|
|
596
|
+
if (!asString(obj.default_workspace)) {
|
|
597
|
+
// Leave the reference CLI's default in charge if it has one; writing ours
|
|
598
|
+
// over it would silently switch a migrating user's workspace.
|
|
599
|
+
const reference = readReferenceCredentials(referenceCredentialsPath());
|
|
600
|
+
if (!reference.defaultWorkspace) obj.default_workspace = slug;
|
|
601
|
+
}
|
|
602
|
+
writeUserObject(path, obj);
|
|
603
|
+
return { path, storage, keyringLabel: backend?.label };
|
|
908
604
|
}
|
|
909
605
|
|
|
910
606
|
/** Set `default_workspace`. Errors if that workspace is not configured anywhere. */
|
|
911
607
|
export function setDefaultWorkspace(slug: string): string {
|
|
912
608
|
const path = userConfigPath();
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
return path;
|
|
925
|
-
});
|
|
609
|
+
const known = readUserConfig(path).workspaces;
|
|
610
|
+
if (!known[slug]) {
|
|
611
|
+
throw new CliError(
|
|
612
|
+
`Workspace '${slug}' is not configured. Run \`linear auth login --workspace ${slug}\` first.`,
|
|
613
|
+
"not_found",
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
const obj = readUserObject(path);
|
|
617
|
+
obj.default_workspace = slug;
|
|
618
|
+
writeUserObject(path, obj);
|
|
619
|
+
return path;
|
|
926
620
|
}
|
|
927
621
|
|
|
928
622
|
/**
|
|
@@ -952,33 +646,31 @@ function removeFromReferenceCredentials(slug: string): boolean {
|
|
|
952
646
|
*/
|
|
953
647
|
export function removeCredential(slug: string): boolean {
|
|
954
648
|
const path = userConfigPath();
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
return removed;
|
|
981
|
-
});
|
|
649
|
+
let removed = false;
|
|
650
|
+
|
|
651
|
+
const backend = keyring();
|
|
652
|
+
if (backend && backend.delete(slug)) removed = true;
|
|
653
|
+
if (removeFromReferenceCredentials(slug)) removed = true;
|
|
654
|
+
|
|
655
|
+
if (!existsSync(path)) return removed;
|
|
656
|
+
const obj = readUserObject(path);
|
|
657
|
+
const ws = workspacesTable(obj);
|
|
658
|
+
const listed = Boolean(ws[slug]);
|
|
659
|
+
if (listed) {
|
|
660
|
+
delete ws[slug];
|
|
661
|
+
removed = true;
|
|
662
|
+
}
|
|
663
|
+
if (Object.keys(ws).length === 0) delete obj.workspaces;
|
|
664
|
+
|
|
665
|
+
if (asString(obj.default_workspace) === slug) {
|
|
666
|
+
const remaining = obj.workspaces ? Object.keys(obj.workspaces as object) : [];
|
|
667
|
+
if (remaining.length > 0) obj.default_workspace = remaining[0]!;
|
|
668
|
+
else delete obj.default_workspace;
|
|
669
|
+
writeUserObject(path, obj);
|
|
670
|
+
} else if (listed) {
|
|
671
|
+
writeUserObject(path, obj);
|
|
672
|
+
}
|
|
673
|
+
return removed;
|
|
982
674
|
}
|
|
983
675
|
|
|
984
676
|
export interface MigrateResult {
|
|
@@ -1002,38 +694,36 @@ export function migrateCredentials(): MigrateResult {
|
|
|
1002
694
|
"runtime",
|
|
1003
695
|
);
|
|
1004
696
|
}
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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
|
-
);
|
|
697
|
+
const obj = readUserObject(path);
|
|
698
|
+
const ws = workspacesTable(obj);
|
|
699
|
+
const plaintext = Object.entries(ws).filter(([, t]) => asString(t.api_key));
|
|
700
|
+
const migrated: string[] = [];
|
|
701
|
+
try {
|
|
702
|
+
for (const [slug, table] of plaintext) {
|
|
703
|
+
backend.set(slug, table.api_key!);
|
|
704
|
+
migrated.push(slug);
|
|
1027
705
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
706
|
+
} catch (err) {
|
|
707
|
+
for (const slug of migrated) {
|
|
708
|
+
try {
|
|
709
|
+
backend.delete(slug);
|
|
710
|
+
} catch {
|
|
711
|
+
// Best-effort rollback.
|
|
1032
712
|
}
|
|
1033
|
-
writeUserObject(path, obj);
|
|
1034
713
|
}
|
|
1035
|
-
|
|
1036
|
-
|
|
714
|
+
throw new CliError(
|
|
715
|
+
`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.`,
|
|
716
|
+
"runtime",
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
if (migrated.length > 0) {
|
|
720
|
+
for (const [, table] of plaintext) {
|
|
721
|
+
delete table.api_key;
|
|
722
|
+
table.keyring = true;
|
|
723
|
+
}
|
|
724
|
+
writeUserObject(path, obj);
|
|
725
|
+
}
|
|
726
|
+
return { migrated, path, keyringLabel: backend.label };
|
|
1037
727
|
}
|
|
1038
728
|
|
|
1039
729
|
export interface CredentialEntry {
|
|
@@ -1073,7 +763,7 @@ export function redactKey(key: string | undefined): string {
|
|
|
1073
763
|
// ---------------------------------------------------------------------------
|
|
1074
764
|
|
|
1075
765
|
/** The settings `config set` / `config init` will write to a project file. */
|
|
1076
|
-
export const SETTABLE_KEYS = ["team", "workspace", "sort"] as const;
|
|
766
|
+
export const SETTABLE_KEYS = ["team", "workspace", "sort", "vcs"] as const;
|
|
1077
767
|
export type SettableKey = (typeof SETTABLE_KEYS)[number];
|
|
1078
768
|
|
|
1079
769
|
/**
|
|
@@ -1085,6 +775,7 @@ const KEY_ALIASES: Record<SettableKey, readonly string[]> = {
|
|
|
1085
775
|
team: ["team", "team_id"],
|
|
1086
776
|
workspace: ["workspace"],
|
|
1087
777
|
sort: ["sort", "issue_sort"],
|
|
778
|
+
vcs: ["vcs"],
|
|
1088
779
|
};
|
|
1089
780
|
|
|
1090
781
|
/** Keys that must never be written to a project file, and why. */
|
|
@@ -1141,72 +832,64 @@ export function setConfigKey(
|
|
|
1141
832
|
opts: { mode?: number } = {},
|
|
1142
833
|
): string {
|
|
1143
834
|
const mode = opts.mode ?? 0o644;
|
|
1144
|
-
const
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
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
|
-
};
|
|
835
|
+
const text = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
836
|
+
// Parse first so a broken file is reported as such rather than edited blind.
|
|
837
|
+
if (text.trim()) parseTomlFile(path);
|
|
838
|
+
|
|
839
|
+
const lines = text.split("\n");
|
|
840
|
+
// Only the top-level region (before the first table header) is ours to
|
|
841
|
+
// touch: `api_key = …` inside `[workspaces.x]` must not be mistaken for a
|
|
842
|
+
// top-level key of the same name.
|
|
843
|
+
const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
|
|
844
|
+
const topEnd = firstTable === -1 ? lines.length : firstTable;
|
|
845
|
+
const spellings = KEY_ALIASES[key];
|
|
846
|
+
const keyLine = new RegExp(`^\\s*(${spellings.map((s) => s.replace(".", "\\.")).join("|")})\\s*=`);
|
|
847
|
+
const idx = lines.findIndex((l, i) => i < topEnd && keyLine.test(l));
|
|
848
|
+
|
|
849
|
+
let spelling: string;
|
|
850
|
+
let next: string[];
|
|
851
|
+
if (idx !== -1) {
|
|
852
|
+
spelling = lines[idx]!.match(keyLine)![1]!;
|
|
853
|
+
// Keep a trailing comment on the line, if any.
|
|
854
|
+
const comment = lines[idx]!.match(/\s+#.*$/)?.[0] ?? "";
|
|
855
|
+
next = [...lines];
|
|
856
|
+
next[idx] = `${spelling} = ${tomlString(value)}${comment}`;
|
|
857
|
+
} else {
|
|
858
|
+
spelling = key;
|
|
859
|
+
next = [...lines];
|
|
860
|
+
// Drop the trailing empty line a `\n`-terminated file splits into (the
|
|
861
|
+
// file is re-terminated below), then insert right after the last
|
|
862
|
+
// non-blank top-level line — before any blank line that separates the
|
|
863
|
+
// top-level keys from the first table. No alias line exists up there (we
|
|
864
|
+
// searched), so the insert is unambiguous.
|
|
865
|
+
if (next[next.length - 1] === "") next.pop();
|
|
866
|
+
let at = firstTable === -1 ? next.length : firstTable;
|
|
867
|
+
while (at > 0 && next[at - 1]!.trim() === "") at--;
|
|
868
|
+
const inserted = [`${spelling} = ${tomlString(value)}`];
|
|
869
|
+
// A file that opens with a table needs a blank line between us and it.
|
|
870
|
+
if (at < next.length && /^\s*\[/.test(next[at]!)) inserted.push("");
|
|
871
|
+
next.splice(at, 0, ...inserted);
|
|
872
|
+
}
|
|
873
|
+
let body = next.join("\n");
|
|
874
|
+
if (!body.endsWith("\n")) body += "\n";
|
|
1206
875
|
|
|
1207
|
-
//
|
|
1208
|
-
//
|
|
1209
|
-
|
|
876
|
+
// Prove the edit: the parsed file must carry exactly this value under this
|
|
877
|
+
// spelling. If a layout we did not foresee defeats the line edit, fall back
|
|
878
|
+
// to a full round-trip — correct, if less pretty.
|
|
879
|
+
let parsed: Record<string, unknown> | undefined;
|
|
880
|
+
try {
|
|
881
|
+
parsed = parseToml(body) as Record<string, unknown>;
|
|
882
|
+
} catch {
|
|
883
|
+
parsed = undefined;
|
|
884
|
+
}
|
|
885
|
+
if (!parsed || parsed[spelling] !== value) {
|
|
886
|
+
const obj = text.trim() ? parseTomlFile(path) : {};
|
|
887
|
+
for (const s of spellings) if (s !== spelling) delete obj[s];
|
|
888
|
+
obj[spelling] = value;
|
|
889
|
+
body = stringifyToml(obj) + "\n";
|
|
890
|
+
}
|
|
891
|
+
writeFileAtomic(path, body, mode);
|
|
892
|
+
return spelling;
|
|
1210
893
|
}
|
|
1211
894
|
|
|
1212
895
|
/**
|