@solaqua/gji 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +24 -30
  2. package/dist/browser.d.ts +7 -0
  3. package/dist/browser.js +37 -0
  4. package/dist/clean.js +34 -19
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +59 -4
  7. package/dist/doctor.d.ts +28 -0
  8. package/dist/doctor.js +454 -0
  9. package/dist/gji-bundle.mjs +2514 -378
  10. package/dist/go.d.ts +2 -1
  11. package/dist/go.js +4 -1
  12. package/dist/index.js +12 -6
  13. package/dist/init.d.ts +15 -0
  14. package/dist/init.js +232 -16
  15. package/dist/open.d.ts +2 -1
  16. package/dist/open.js +2 -1
  17. package/dist/pr-open.d.ts +19 -0
  18. package/dist/pr-open.js +290 -0
  19. package/dist/pull-requests.d.ts +33 -0
  20. package/dist/pull-requests.js +384 -0
  21. package/dist/remove.js +2 -2
  22. package/dist/repo-registry.d.ts +5 -0
  23. package/dist/repo-registry.js +87 -14
  24. package/dist/shell-completion.js +278 -10
  25. package/dist/shell-setup.d.ts +9 -0
  26. package/dist/shell-setup.js +56 -0
  27. package/dist/warp.d.ts +3 -0
  28. package/dist/warp.js +8 -3
  29. package/dist/worktree-management.d.ts +2 -1
  30. package/dist/worktree-management.js +23 -7
  31. package/dist/worktree-picker.d.ts +12 -1
  32. package/dist/worktree-picker.js +84 -6
  33. package/man/man1/gji-back.1 +1 -1
  34. package/man/man1/gji-clean.1 +1 -1
  35. package/man/man1/gji-completion.1 +1 -1
  36. package/man/man1/gji-config.1 +1 -1
  37. package/man/man1/gji-doctor.1 +19 -0
  38. package/man/man1/gji-go.1 +1 -1
  39. package/man/man1/gji-history.1 +1 -1
  40. package/man/man1/gji-init.1 +6 -3
  41. package/man/man1/gji-ls.1 +1 -1
  42. package/man/man1/gji-new.1 +1 -1
  43. package/man/man1/gji-open.1 +1 -1
  44. package/man/man1/gji-pr.1 +5 -1
  45. package/man/man1/gji-remove.1 +1 -1
  46. package/man/man1/gji-root.1 +1 -1
  47. package/man/man1/gji-run-hook.1 +1 -1
  48. package/man/man1/gji-status.1 +1 -1
  49. package/man/man1/gji-sync-files.1 +1 -1
  50. package/man/man1/gji-sync.1 +1 -1
  51. package/man/man1/gji-warp.1 +1 -1
  52. package/man/man1/gji.1 +6 -2
  53. package/package.json +1 -1
@@ -0,0 +1,384 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ const PULL_REQUEST_QUERY_TIMEOUT_MS = 2500;
5
+ export function parsePullRequestRemote(remoteUrl) {
6
+ const parsed = parseRemoteUrl(remoteUrl.trim());
7
+ if (parsed === null)
8
+ return null;
9
+ const forge = detectForge(parsed.host);
10
+ if (forge === null)
11
+ return null;
12
+ if (forge === "bitbucket" && parsed.host !== "bitbucket.org")
13
+ return null;
14
+ const path = parsed.path
15
+ .replace(/^\/+/, "")
16
+ .replace(/\.git$/, "")
17
+ .split("/")
18
+ .filter(Boolean);
19
+ if (path.length < 2)
20
+ return null;
21
+ if (forge === "gitlab") {
22
+ const repository = path.at(-1);
23
+ const namespace = path.slice(0, -1).join("/");
24
+ if (repository === undefined || namespace.length === 0)
25
+ return null;
26
+ return {
27
+ forge,
28
+ host: parsed.host,
29
+ namespace,
30
+ repository,
31
+ webBaseUrl: `https://${parsed.host}`,
32
+ };
33
+ }
34
+ const namespace = path.at(-2);
35
+ const repository = path.at(-1);
36
+ if (namespace === undefined || repository === undefined)
37
+ return null;
38
+ return {
39
+ forge,
40
+ host: parsed.host,
41
+ namespace,
42
+ repository,
43
+ webBaseUrl: `https://${parsed.host}`,
44
+ };
45
+ }
46
+ export function createPullRequestQuery(dependencies = {}) {
47
+ const runCommand = dependencies.runCommand ?? defaultRunCommand;
48
+ const fetcher = dependencies.fetch ?? fetch;
49
+ return {
50
+ findOpenPullRequest: (repoRoot, number) => findOpenPullRequest(repoRoot, number, runCommand, fetcher),
51
+ listOpenPullRequests: (repoRoot, sourceBranch) => listOpenPullRequests(repoRoot, sourceBranch, runCommand, fetcher),
52
+ listOpenPullRequestsForRepository: (repoRoot) => listOpenPullRequestsForRepository(repoRoot, runCommand, fetcher),
53
+ };
54
+ }
55
+ async function listOpenPullRequests(repoRoot, sourceBranch, runCommand, fetcher) {
56
+ const remote = await readPullRequestRemote(repoRoot, runCommand);
57
+ const cliResult = await queryProviderCli(remote, { kind: "branch", sourceBranch }, repoRoot, runCommand);
58
+ if (cliResult !== null)
59
+ return sortPullRequests(cliResult);
60
+ const apiResult = await queryPublicApi(remote, { kind: "branch", sourceBranch }, fetcher);
61
+ return sortPullRequests(apiResult);
62
+ }
63
+ async function listOpenPullRequestsForRepository(repoRoot, runCommand, fetcher) {
64
+ const remote = await readPullRequestRemote(repoRoot, runCommand);
65
+ const cliResult = await queryProviderCli(remote, { kind: "repository" }, repoRoot, runCommand);
66
+ if (cliResult !== null)
67
+ return sortPullRequests(cliResult);
68
+ const apiResult = await queryPublicApi(remote, { kind: "repository" }, fetcher);
69
+ return sortPullRequests(apiResult);
70
+ }
71
+ async function findOpenPullRequest(repoRoot, number, runCommand, fetcher) {
72
+ const remote = await readPullRequestRemote(repoRoot, runCommand);
73
+ const cliResult = await queryProviderCli(remote, { kind: "number", number }, repoRoot, runCommand);
74
+ if (cliResult !== null)
75
+ return cliResult[0] ?? null;
76
+ const apiResult = await queryPublicApi(remote, { kind: "number", number }, fetcher);
77
+ return apiResult[0] ?? null;
78
+ }
79
+ async function readPullRequestRemote(repoRoot, runCommand) {
80
+ const result = await runCommand("git", ["remote", "get-url", "origin"], commandOptions(repoRoot));
81
+ const remote = parsePullRequestRemote(result.stdout);
82
+ if (remote === null) {
83
+ throw new Error("origin remote is unsupported or missing");
84
+ }
85
+ return remote;
86
+ }
87
+ async function queryProviderCli(remote, query, repoRoot, runCommand) {
88
+ const providerCommand = providerCliCommand(remote, query);
89
+ try {
90
+ const result = await runCommand(providerCommand.command, providerCommand.args, commandOptions(repoRoot));
91
+ return parseProviderOutput(remote, result.stdout, query);
92
+ }
93
+ catch {
94
+ // Missing CLIs, unauthenticated CLIs, and CLI/network failures all use the public API fallback.
95
+ return null;
96
+ }
97
+ }
98
+ async function queryPublicApi(remote, query, fetcher) {
99
+ const url = publicApiUrl(remote, query);
100
+ const controller = new AbortController();
101
+ const timer = setTimeout(() => controller.abort(), PULL_REQUEST_QUERY_TIMEOUT_MS);
102
+ try {
103
+ const response = await fetcher(url, {
104
+ headers: { Accept: "application/json" },
105
+ signal: controller.signal,
106
+ });
107
+ if (response.status === 404 && query.kind === "number")
108
+ return [];
109
+ if (!response.ok) {
110
+ throw new Error(`public API returned HTTP ${response.status}`);
111
+ }
112
+ const payload = await response.json();
113
+ return parseApiOutput(remote, payload, query);
114
+ }
115
+ finally {
116
+ clearTimeout(timer);
117
+ }
118
+ }
119
+ function parseRemoteUrl(remoteUrl) {
120
+ const scpMatch = remoteUrl.match(/^[^@]+@([^:]+):(.+)$/);
121
+ if (scpMatch) {
122
+ return { host: scpMatch[1].toLowerCase(), path: scpMatch[2] };
123
+ }
124
+ try {
125
+ const url = new URL(remoteUrl);
126
+ if (!url.hostname)
127
+ return null;
128
+ return { host: url.hostname.toLowerCase(), path: url.pathname };
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ }
134
+ function detectForge(host) {
135
+ if (host === "github.com" || host.includes("github"))
136
+ return "github";
137
+ if (host === "gitlab.com" || host.includes("gitlab"))
138
+ return "gitlab";
139
+ if (host === "bitbucket.org" || host.includes("bitbucket")) {
140
+ return "bitbucket";
141
+ }
142
+ return null;
143
+ }
144
+ function providerCliCommand(remote, query) {
145
+ const coordinate = providerRepositoryCoordinate(remote);
146
+ const sourceBranch = query.sourceBranch ?? "";
147
+ switch (remote.forge) {
148
+ case "github": {
149
+ if (query.kind === "number") {
150
+ return {
151
+ args: [
152
+ "pr",
153
+ "view",
154
+ String(query.number),
155
+ "--json",
156
+ "number,url,headRefName,state",
157
+ "--repo",
158
+ coordinate,
159
+ ],
160
+ command: "gh",
161
+ };
162
+ }
163
+ return {
164
+ args: [
165
+ "pr",
166
+ "list",
167
+ "--state",
168
+ "open",
169
+ ...(query.kind === "branch" ? ["--head", sourceBranch] : []),
170
+ "--json",
171
+ "number,url,headRefName,state",
172
+ "--limit",
173
+ "100",
174
+ "--repo",
175
+ coordinate,
176
+ ],
177
+ command: "gh",
178
+ };
179
+ }
180
+ case "gitlab": {
181
+ if (query.kind === "number") {
182
+ return {
183
+ args: [
184
+ "mr",
185
+ "view",
186
+ String(query.number),
187
+ "--output",
188
+ "json",
189
+ "--repo",
190
+ coordinate,
191
+ ],
192
+ command: "glab",
193
+ };
194
+ }
195
+ return {
196
+ args: [
197
+ "mr",
198
+ "list",
199
+ "--state",
200
+ "opened",
201
+ ...(query.kind === "branch" ? ["--source-branch", sourceBranch] : []),
202
+ "--output",
203
+ "json",
204
+ "--per-page",
205
+ "100",
206
+ "--repo",
207
+ coordinate,
208
+ ],
209
+ command: "glab",
210
+ };
211
+ }
212
+ case "bitbucket": {
213
+ if (query.kind === "number") {
214
+ return {
215
+ args: [
216
+ "pr",
217
+ "view",
218
+ String(query.number),
219
+ "--format",
220
+ "json",
221
+ "--repo",
222
+ coordinate,
223
+ ],
224
+ command: "bb",
225
+ };
226
+ }
227
+ return {
228
+ args: [
229
+ "pr",
230
+ "list",
231
+ "--state",
232
+ "OPEN",
233
+ ...(query.kind === "branch" ? ["--source", sourceBranch] : []),
234
+ "--format",
235
+ "json",
236
+ "--repo",
237
+ coordinate,
238
+ ],
239
+ command: "bb",
240
+ };
241
+ }
242
+ }
243
+ }
244
+ function providerRepositoryCoordinate(remote) {
245
+ const coordinate = `${remote.namespace}/${remote.repository}`;
246
+ return remote.host === "github.com" || remote.host === "bitbucket.org"
247
+ ? coordinate
248
+ : `${remote.host}/${coordinate}`;
249
+ }
250
+ function parseProviderOutput(remote, stdout, query) {
251
+ const payload = JSON.parse(stdout);
252
+ const values = remote.forge === "bitbucket" && isRecord(payload)
253
+ ? Array.isArray(payload.values)
254
+ ? payload.values
255
+ : []
256
+ : Array.isArray(payload)
257
+ ? payload
258
+ : [payload];
259
+ return values
260
+ .map((value) => normalizePullRequest(value, query))
261
+ .filter((value) => value !== null)
262
+ .filter((value) => query.kind === "branch"
263
+ ? value.sourceBranch === query.sourceBranch
264
+ : query.kind === "number"
265
+ ? value.number === query.number
266
+ : true);
267
+ }
268
+ function parseApiOutput(remote, payload, query) {
269
+ const values = remote.forge === "bitbucket" && isRecord(payload)
270
+ ? Array.isArray(payload.values)
271
+ ? payload.values
272
+ : []
273
+ : query.kind === "number"
274
+ ? [payload]
275
+ : Array.isArray(payload)
276
+ ? payload
277
+ : [];
278
+ return values
279
+ .map((value) => normalizePullRequest(value, query))
280
+ .filter((value) => value !== null)
281
+ .filter((value) => query.kind === "branch"
282
+ ? value.sourceBranch === query.sourceBranch
283
+ : query.kind === "number"
284
+ ? value.number === query.number
285
+ : true);
286
+ }
287
+ function normalizePullRequest(value, query) {
288
+ if (!isRecord(value))
289
+ return null;
290
+ const number = numberValue(value.number ?? value.iid ?? value.id);
291
+ const head = isRecord(value.head) ? value.head : null;
292
+ const source = isRecord(value.source) ? value.source : null;
293
+ const sourceBranch = stringValue(value.headRefName ??
294
+ (head !== null ? head.ref : undefined) ??
295
+ value.source_branch ??
296
+ (source !== null && isRecord(source.branch)
297
+ ? source.branch.name
298
+ : undefined));
299
+ const url = stringValue(value.html_url ??
300
+ value.web_url ??
301
+ value.url ??
302
+ (isRecord(value.links) && isRecord(value.links.html)
303
+ ? value.links.html.href
304
+ : undefined));
305
+ const state = stringValue(value.state)?.toLowerCase();
306
+ if (number === null || url === null)
307
+ return null;
308
+ if (query.kind !== "number" && sourceBranch === null)
309
+ return null;
310
+ if (query.kind === "number" && number !== query.number)
311
+ return null;
312
+ if (state !== undefined && !["open", "opened"].includes(state))
313
+ return null;
314
+ return {
315
+ number,
316
+ sourceBranch: sourceBranch ?? "",
317
+ url,
318
+ };
319
+ }
320
+ function publicApiUrl(remote, query) {
321
+ const branch = encodeURIComponent(query.sourceBranch ?? "");
322
+ const number = query.number === undefined ? "" : String(query.number);
323
+ switch (remote.forge) {
324
+ case "github": {
325
+ const coordinate = `${encodeURIComponent(remote.namespace)}/${encodeURIComponent(remote.repository)}`;
326
+ const apiBase = remote.host === "github.com"
327
+ ? "https://api.github.com"
328
+ : `https://${remote.host}/api/v3`;
329
+ if (query.kind === "number") {
330
+ return `${apiBase}/repos/${coordinate}/pulls/${number}`;
331
+ }
332
+ return `${apiBase}/repos/${coordinate}/pulls?state=open&per_page=100`;
333
+ }
334
+ case "gitlab": {
335
+ const project = encodeURIComponent(`${remote.namespace}/${remote.repository}`);
336
+ if (query.kind === "number") {
337
+ return `${remote.webBaseUrl}/api/v4/projects/${project}/merge_requests/${number}`;
338
+ }
339
+ return query.kind === "branch"
340
+ ? `${remote.webBaseUrl}/api/v4/projects/${project}/merge_requests?state=opened&source_branch=${branch}&per_page=100`
341
+ : `${remote.webBaseUrl}/api/v4/projects/${project}/merge_requests?state=opened&per_page=100`;
342
+ }
343
+ case "bitbucket": {
344
+ const coordinate = `${encodeURIComponent(remote.namespace)}/${encodeURIComponent(remote.repository)}`;
345
+ if (query.kind === "number") {
346
+ return `https://api.bitbucket.org/2.0/repositories/${coordinate}/pullrequests/${number}`;
347
+ }
348
+ return query.kind === "branch"
349
+ ? `https://api.bitbucket.org/2.0/repositories/${coordinate}/pullrequests?state=OPEN&source.branch.name=${branch}&pagelen=100`
350
+ : `https://api.bitbucket.org/2.0/repositories/${coordinate}/pullrequests?state=OPEN&pagelen=100`;
351
+ }
352
+ }
353
+ }
354
+ function sortPullRequests(pullRequests) {
355
+ return [...pullRequests].sort((a, b) => a.number - b.number);
356
+ }
357
+ function commandOptions(cwd) {
358
+ return {
359
+ cwd,
360
+ env: {
361
+ ...process.env,
362
+ GIT_TERMINAL_PROMPT: "0",
363
+ GH_PROMPT_DISABLED: "1",
364
+ GLAB_NON_INTERACTIVE: "1",
365
+ },
366
+ timeout: PULL_REQUEST_QUERY_TIMEOUT_MS,
367
+ };
368
+ }
369
+ async function defaultRunCommand(command, args, options) {
370
+ return execFileAsync(command, args, options);
371
+ }
372
+ function isRecord(value) {
373
+ return typeof value === "object" && value !== null;
374
+ }
375
+ function stringValue(value) {
376
+ return typeof value === "string" && value.length > 0 ? value : null;
377
+ }
378
+ function numberValue(value) {
379
+ if (typeof value === "number" && Number.isInteger(value))
380
+ return value;
381
+ if (typeof value === "string" && /^\d+$/.test(value))
382
+ return Number(value);
383
+ return null;
384
+ }
package/dist/remove.js CHANGED
@@ -4,7 +4,7 @@ import { loadEffectiveConfig } from "./config.js";
4
4
  import { isHeadless } from "./headless.js";
5
5
  import { extractHooks, runHook } from "./hooks.js";
6
6
  import { writeShellOutput } from "./shell-handoff.js";
7
- import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isWorktreeDirtyError, loadLinkedWorktrees, removeWorktree, } from "./worktree-management.js";
7
+ import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isWorktreeForceRemovalError, loadLinkedWorktrees, removeWorktree, } from "./worktree-management.js";
8
8
  import { buildWorktreePromptEntries, promptForSingleWorktree, } from "./worktree-picker.js";
9
9
  import { defaultConfirmForceDeleteBranch, defaultConfirmForceRemoveWorktree, } from "./worktree-prompts.js";
10
10
  const REMOVE_OUTPUT_FILE_ENV = "GJI_REMOVE_OUTPUT_FILE";
@@ -83,7 +83,7 @@ export function createRemoveCommand(dependencies = {}) {
83
83
  await removeWorktree(repository.repoRoot, worktree.path);
84
84
  }
85
85
  catch (error) {
86
- if (!isWorktreeDirtyError(error)) {
86
+ if (!isWorktreeForceRemovalError(error)) {
87
87
  throw error;
88
88
  }
89
89
  if (!options.force &&
@@ -3,6 +3,11 @@ export interface RepoRegistryEntry {
3
3
  name: string;
4
4
  path: string;
5
5
  }
6
+ export interface RegistryRemovalResult {
7
+ removedPaths: string[];
8
+ skippedPaths: string[];
9
+ }
6
10
  export declare function REGISTRY_FILE_PATH(home?: string): string;
7
11
  export declare function loadRegistry(home?: string): Promise<RepoRegistryEntry[]>;
8
12
  export declare function registerRepo(repoPath: string, home?: string): Promise<void>;
13
+ export declare function removeMissingRegistryEntries(paths: ReadonlySet<string>, home?: string): Promise<RegistryRemovalResult>;
@@ -1,9 +1,12 @@
1
- import { mkdir, readFile, realpath, writeFile } from "node:fs/promises";
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { access, mkdir, readFile, realpath, rename, rm, writeFile, } from "node:fs/promises";
2
4
  import { homedir } from "node:os";
3
5
  import { basename, dirname, join, resolve } from "node:path";
4
6
  import { GLOBAL_CONFIG_DIRECTORY } from "./config.js";
5
7
  const REGISTRY_FILE_NAME = "repos.json";
6
8
  const MAX_REGISTRY_ENTRIES = 100;
9
+ const registryLocks = new Map();
7
10
  export function REGISTRY_FILE_PATH(home = homedir()) {
8
11
  const configDir = process.env.GJI_CONFIG_DIR;
9
12
  if (configDir) {
@@ -34,20 +37,90 @@ async function canonicalizeRepoPath(repoPath) {
34
37
  }
35
38
  export async function registerRepo(repoPath, home = homedir()) {
36
39
  const registryPath = REGISTRY_FILE_PATH(home);
37
- const existing = await normalizeRegistryForWrite(await loadRegistry(home));
38
- const canonicalRepoPath = await canonicalizeRepoPath(repoPath);
39
- // Skip write if this repo is already the most-recently-used entry (common case).
40
- if (existing.length > 0 && existing[0].path === canonicalRepoPath)
41
- return;
42
- const entry = {
43
- lastUsed: Date.now(),
44
- name: basename(canonicalRepoPath),
45
- path: canonicalRepoPath,
46
- };
47
- const filtered = existing.filter((e) => e.path !== canonicalRepoPath);
48
- const next = [entry, ...filtered].slice(0, MAX_REGISTRY_ENTRIES);
40
+ await withRegistryLock(registryPath, async () => {
41
+ const existing = await normalizeRegistryForWrite(await loadRegistry(home));
42
+ const canonicalRepoPath = await canonicalizeRepoPath(repoPath);
43
+ // Skip write if this repo is already the most-recently-used entry (common case).
44
+ if (existing.length > 0 && existing[0].path === canonicalRepoPath)
45
+ return;
46
+ const entry = {
47
+ lastUsed: Date.now(),
48
+ name: basename(canonicalRepoPath),
49
+ path: canonicalRepoPath,
50
+ };
51
+ const filtered = existing.filter((e) => e.path !== canonicalRepoPath);
52
+ const next = [entry, ...filtered].slice(0, MAX_REGISTRY_ENTRIES);
53
+ await writeRegistry(next, registryPath);
54
+ });
55
+ }
56
+ export async function removeMissingRegistryEntries(paths, home = homedir()) {
57
+ if (paths.size === 0)
58
+ return { removedPaths: [], skippedPaths: [] };
59
+ const registryPath = REGISTRY_FILE_PATH(home);
60
+ return withRegistryLock(registryPath, async () => {
61
+ const entries = await loadRegistry(home);
62
+ const candidates = entries.filter((entry) => paths.has(entry.path));
63
+ const status = await Promise.all(candidates.map(async (entry) => ({
64
+ entry,
65
+ missing: await isMissingPath(entry.path),
66
+ })));
67
+ const removedPaths = status
68
+ .filter(({ missing }) => missing)
69
+ .map(({ entry }) => entry.path);
70
+ const skippedPaths = status
71
+ .filter(({ missing }) => !missing)
72
+ .map(({ entry }) => entry.path);
73
+ if (removedPaths.length > 0) {
74
+ await writeRegistry(entries.filter((entry) => !removedPaths.includes(entry.path)), registryPath);
75
+ }
76
+ return { removedPaths, skippedPaths };
77
+ });
78
+ }
79
+ async function withRegistryLock(registryPath, operation) {
80
+ const previous = registryLocks.get(registryPath);
81
+ let release;
82
+ const current = new Promise((resolve) => {
83
+ release = resolve;
84
+ });
85
+ registryLocks.set(registryPath, current);
86
+ if (previous)
87
+ await previous;
88
+ try {
89
+ return await operation();
90
+ }
91
+ finally {
92
+ release();
93
+ if (registryLocks.get(registryPath) === current) {
94
+ registryLocks.delete(registryPath);
95
+ }
96
+ }
97
+ }
98
+ async function isMissingPath(path) {
99
+ try {
100
+ await access(path, constants.F_OK);
101
+ return false;
102
+ }
103
+ catch (error) {
104
+ return isMissingPathError(error);
105
+ }
106
+ }
107
+ function isMissingPathError(error) {
108
+ if (!(error instanceof Error) || !("code" in error))
109
+ return false;
110
+ const code = error.code;
111
+ return code === "ENOENT" || code === "ENOTDIR";
112
+ }
113
+ async function writeRegistry(entries, registryPath) {
49
114
  await mkdir(dirname(registryPath), { recursive: true });
50
- await writeFile(registryPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
115
+ const temporaryPath = `${registryPath}.tmp-${process.pid}-${randomUUID()}`;
116
+ try {
117
+ await writeFile(temporaryPath, `${JSON.stringify(entries, null, 2)}\n`, "utf8");
118
+ await rename(temporaryPath, registryPath);
119
+ }
120
+ catch (error) {
121
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
122
+ throw error;
123
+ }
51
124
  }
52
125
  async function normalizeRegistryForWrite(entries) {
53
126
  const normalized = [];