@giovannijecha/jecode 0.8.4 → 0.8.6

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 (99) hide show
  1. package/README.md +28 -9
  2. package/dist/accounts.js +47 -10
  3. package/dist/atomic.js +24 -14
  4. package/dist/batch-view.js +27 -2
  5. package/dist/batch.js +39 -4
  6. package/dist/bounded-file.js +212 -0
  7. package/dist/cli-info.js +0 -1
  8. package/dist/commands.js +2 -0
  9. package/dist/config.js +16 -7
  10. package/dist/context/automatic.js +35 -0
  11. package/dist/context/compactor.js +32 -2
  12. package/dist/context/manual.js +20 -3
  13. package/dist/context/request-projection.js +130 -0
  14. package/dist/controller-request.js +33 -11
  15. package/dist/credential-commands.js +25 -9
  16. package/dist/credentials.js +56 -18
  17. package/dist/directory-anchor.js +91 -0
  18. package/dist/file-identity.js +12 -0
  19. package/dist/model-command.js +5 -4
  20. package/dist/openai-account-command.js +23 -10
  21. package/dist/openai-account.js +7 -5
  22. package/dist/openai-oauth-callback.js +13 -4
  23. package/dist/openai-oauth-tokens.js +9 -7
  24. package/dist/openai-oauth.js +8 -6
  25. package/dist/permission-command.js +1 -1
  26. package/dist/process-lease.js +329 -0
  27. package/dist/provider-commands.js +11 -37
  28. package/dist/provider-errors.js +31 -7
  29. package/dist/provider-label.js +9 -3
  30. package/dist/providers/anthropic-stream.js +4 -1
  31. package/dist/providers/anthropic-wire.js +8 -3
  32. package/dist/providers/anthropic.js +39 -20
  33. package/dist/providers/catalog.js +4 -4
  34. package/dist/providers/failure.js +181 -0
  35. package/dist/providers/http.js +82 -23
  36. package/dist/providers/index.js +1 -5
  37. package/dist/providers/ollama-context.js +42 -0
  38. package/dist/providers/ollama-endpoint.js +7 -34
  39. package/dist/providers/ollama-stream.js +5 -1
  40. package/dist/providers/ollama.js +36 -158
  41. package/dist/providers/openai-codex.js +71 -45
  42. package/dist/providers/openai-stream.js +26 -2
  43. package/dist/providers/openai.js +51 -25
  44. package/dist/providers/sse.js +52 -8
  45. package/dist/request-identity.js +32 -0
  46. package/dist/sessions/bucket.js +55 -0
  47. package/dist/sessions/catalog-io.js +162 -0
  48. package/dist/sessions/catalog.js +3 -1
  49. package/dist/sessions/codec-messages.js +122 -0
  50. package/dist/sessions/codec-transcript.js +93 -0
  51. package/dist/sessions/codec-values.js +52 -0
  52. package/dist/sessions/codec.js +4 -257
  53. package/dist/sessions/files.js +158 -0
  54. package/dist/sessions/lease.js +132 -49
  55. package/dist/sessions/load.js +90 -0
  56. package/dist/sessions/runtime.js +15 -8
  57. package/dist/sessions/snapshot.js +33 -0
  58. package/dist/sessions/store.js +183 -378
  59. package/dist/settings-command.js +10 -5
  60. package/dist/settings.js +75 -22
  61. package/dist/stable-directory.js +148 -0
  62. package/dist/start.js +1 -2
  63. package/dist/store-lock.js +68 -84
  64. package/dist/tools/args.js +2 -2
  65. package/dist/tools/file-read.js +192 -0
  66. package/dist/tools/file-summary.js +9 -0
  67. package/dist/tools/{fs.js → file-write.js} +5 -171
  68. package/dist/tools/glob.js +107 -0
  69. package/dist/tools/index.js +2 -1
  70. package/dist/tools/search.js +66 -205
  71. package/dist/tools/text-boundary.js +7 -33
  72. package/dist/tui/app-workflows.js +9 -334
  73. package/dist/tui/approve.js +5 -3
  74. package/dist/tui/blocks.js +8 -7
  75. package/dist/tui/command-workflow.js +106 -0
  76. package/dist/tui/components/command-menu.js +7 -10
  77. package/dist/tui/components/footer.js +1 -1
  78. package/dist/tui/components/menu.js +74 -43
  79. package/dist/tui/components/messages.js +16 -9
  80. package/dist/tui/components/tool-evidence.js +107 -0
  81. package/dist/tui/components/tool-motion.js +32 -0
  82. package/dist/tui/components/tool.js +48 -202
  83. package/dist/tui/feedback.js +4 -0
  84. package/dist/tui/help.js +1 -1
  85. package/dist/tui/picker-layout.js +40 -0
  86. package/dist/tui/picker.js +7 -71
  87. package/dist/tui/session-view.js +7 -2
  88. package/dist/tui/tool-details.js +135 -0
  89. package/dist/tui/transcript-grammar.js +8 -1
  90. package/dist/tui/transcript-view.js +26 -112
  91. package/dist/tui/turn-workflow.js +264 -0
  92. package/dist/tui/turn.js +7 -140
  93. package/dist/tui/workflow-types.js +2 -0
  94. package/dist/tui/workspace.js +21 -7
  95. package/dist/user-store.js +23 -31
  96. package/package.json +14 -15
  97. package/dist/ollama-settings-command.js +0 -74
  98. package/dist/tools/ripgrep.js +0 -230
  99. package/dist/tui/motion.js +0 -32
@@ -69,26 +69,31 @@ function settingsItems(values) {
69
69
  option: {
70
70
  label: "model",
71
71
  value: `${providerLabel(values.provider)} · ${values.model || "choose a model"}`,
72
+ description: "Choose the provider and model used for subsequent requests.",
72
73
  },
73
74
  },
74
- { action: "effort", option: { label: "effort", value: values.effort } },
75
+ { action: "effort", option: { label: "effort", value: values.effort,
76
+ description: "Set the saved reasoning effort for model requests." } },
75
77
  ...(values.maxTokens === undefined
76
78
  ? []
77
79
  : [{
78
80
  action: "maxTokens",
79
- option: { label: "max output tokens", value: String(values.maxTokens) },
81
+ option: { label: "max output tokens", value: String(values.maxTokens),
82
+ description: "Limit the output tokens requested from the model in each response." },
80
83
  }]),
81
84
  {
82
85
  action: "compactionPercent",
83
- option: { label: "context compaction", value: `${values.compactionPercent}%` },
86
+ option: { label: "context compaction", value: `${values.compactionPercent}%`,
87
+ description: "Compact model context near this share of its usable window. The transcript stays complete." },
84
88
  },
85
89
  {
86
90
  action: "reducedMotion",
87
- option: { label: "reduced motion", value: values.reducedMotion ? "on" : "off" },
91
+ option: { label: "reduced motion", value: values.reducedMotion ? "on" : "off",
92
+ description: "Keep decorative movement still while output continues to update." },
88
93
  },
89
94
  {
90
95
  action: "providers",
91
- option: { label: "providers", hint: "manage connections" },
96
+ option: { label: "providers", hint: "manage connections", description: "Manage API keys and account connections." },
92
97
  },
93
98
  ];
94
99
  }
package/dist/settings.js CHANGED
@@ -1,14 +1,14 @@
1
1
  // Persistent, non-secret defaults for interactive and batch sessions.
2
- import { chmod, mkdir } from "node:fs/promises";
3
2
  import * as path from "node:path";
4
3
  import { atomicWrite } from "./atomic.js";
4
+ import { assertDirectoryAnchor, captureDirectDirectorySync, preparePrivateDirectory, } from "./directory-anchor.js";
5
5
  import { MAX_COMPACTION_PERCENT, MIN_COMPACTION_PERCENT } from "./context/policy.js";
6
6
  import { EFFORTS } from "./effort.js";
7
7
  import { providerNames } from "./providers/index.js";
8
- import { parseOllamaEndpoint } from "./providers/ollama-endpoint.js";
8
+ import { isLegacyOllamaCloudHost, OLLAMA_CLOUD_HOST } from "./providers/ollama-endpoint.js";
9
9
  import { withStoreLock } from "./store-lock.js";
10
10
  import { userDataLabel, userDataPath } from "./user-data.js";
11
- import { assertStoreText, readBoundedJsonSync, USER_STORE_LIMITS } from "./user-store.js";
11
+ import { assertStoreText, readBoundedJsonForMutationSync, readBoundedJsonSync, USER_STORE_LIMITS, } from "./user-store.js";
12
12
  export { EFFORTS } from "./effort.js";
13
13
  let saved;
14
14
  export function readSettings() {
@@ -17,19 +17,23 @@ export function readSettings() {
17
17
  return copy(saved);
18
18
  }
19
19
  export async function updateSettings(patch) {
20
+ assertRetiredOllamaHost(patch);
20
21
  const file = settingsPath();
21
22
  const directory = path.dirname(file);
22
- await mkdir(directory, { recursive: true, mode: 0o700 });
23
- if (process.platform !== "win32")
24
- await chmod(directory, 0o700);
25
- return withStoreLock(file, async () => {
26
- const next = normalize({ ...readStore(file), ...patch });
23
+ const anchor = await preparePrivateDirectory(directory, "settings store directory");
24
+ const anchoredFile = path.join(anchor.path, path.basename(file));
25
+ return withStoreLock(anchoredFile, async () => {
26
+ const next = normalize({ ...readStoreForMutation(anchoredFile, anchor), ...patch });
27
+ delete next.ollamaHost;
27
28
  const text = `${JSON.stringify(next, null, 2)}\n`;
28
29
  assertStoreText(text, USER_STORE_LIMITS.settingsBytes);
29
- await atomicWrite(file, text, { mode: 0o600 });
30
+ await atomicWrite(anchoredFile, text, {
31
+ mode: 0o600,
32
+ validate: async () => assertDirectoryAnchor(anchor),
33
+ });
30
34
  saved = next;
31
35
  return file;
32
- });
36
+ }, undefined, async () => assertDirectoryAnchor(anchor));
33
37
  }
34
38
  export function settingsPath() {
35
39
  return userDataPath("settings.json");
@@ -43,21 +47,69 @@ export function reloadSettings() {
43
47
  }
44
48
  function readStore(file = settingsPath()) {
45
49
  try {
46
- return normalize(readBoundedJsonSync(file, USER_STORE_LIMITS.settingsBytes));
50
+ const directory = captureDirectDirectorySync(path.dirname(file), "settings store directory");
51
+ const anchoredFile = path.join(directory.path, path.basename(file));
52
+ return normalize(readBoundedJsonSync(anchoredFile, USER_STORE_LIMITS.settingsBytes, directory));
47
53
  }
48
54
  catch {
49
- // Missing, unreadable, and malformed stores all fall back safely. A bad
50
- // preference must never prevent the agent from starting.
55
+ // Missing, unreadable, and malformed stores fall back safely. Parsed
56
+ // retired endpoint markers survive normalization for startup validation.
51
57
  return {};
52
58
  }
53
59
  }
60
+ function readStoreForMutation(file, directory) {
61
+ const value = readBoundedJsonForMutationSync(file, USER_STORE_LIMITS.settingsBytes, "settings store", directory);
62
+ if (value === undefined)
63
+ return {};
64
+ if (!record(value))
65
+ throw new Error("settings store has an unsupported structure");
66
+ assertMutableSettings(value);
67
+ return normalize(value);
68
+ }
69
+ function assertMutableSettings(value) {
70
+ const current = new Set([
71
+ "provider",
72
+ "models",
73
+ "ollamaHost",
74
+ "effort",
75
+ "reducedMotion",
76
+ "maxTokens",
77
+ "compactionPercent",
78
+ ]);
79
+ const retired = new Set(["theme", "palette", "maxSteps"]);
80
+ if (Object.keys(value).some((key) => !current.has(key) && !retired.has(key))) {
81
+ throw new Error("settings store has an unsupported structure");
82
+ }
83
+ const providers = providerNames();
84
+ if ("provider" in value && member(value["provider"], providers) === undefined) {
85
+ throw new Error("settings store has an invalid provider");
86
+ }
87
+ if ("models" in value) {
88
+ const models = value["models"];
89
+ if (!record(models) || Object.keys(models).some((provider) => !providers.includes(provider)) ||
90
+ Object.values(models).some((model) => !boundedNonempty(model, USER_STORE_LIMITS.model)))
91
+ throw new Error("settings store has invalid models");
92
+ }
93
+ assertRetiredOllamaHost(value);
94
+ if ("effort" in value && member(value["effort"], EFFORTS) === undefined) {
95
+ throw new Error("settings store has an invalid reasoning effort");
96
+ }
97
+ if ("reducedMotion" in value && typeof value["reducedMotion"] !== "boolean") {
98
+ throw new Error("settings store has an invalid reduced-motion preference");
99
+ }
100
+ if ("maxTokens" in value && positiveInteger(value["maxTokens"]) === undefined) {
101
+ throw new Error("settings store has an invalid token limit");
102
+ }
103
+ if ("compactionPercent" in value && percentage(value["compactionPercent"]) === undefined)
104
+ throw new Error("settings store has an invalid compaction threshold");
105
+ }
54
106
  function normalize(value) {
55
107
  if (!record(value))
56
108
  return {};
57
109
  const providers = providerNames();
58
110
  const provider = member(value["provider"], providers);
59
111
  const models = modelsOf(value["models"], providers);
60
- const ollamaHost = endpoint(value["ollamaHost"]);
112
+ const ollamaHost = "ollamaHost" in value ? legacyOllamaHost(value["ollamaHost"]) : undefined;
61
113
  const effort = member(value["effort"], EFFORTS);
62
114
  const reducedMotion = typeof value["reducedMotion"] === "boolean" ? value["reducedMotion"] : undefined;
63
115
  const maxTokens = positiveInteger(value["maxTokens"]);
@@ -81,14 +133,15 @@ function modelsOf(value, providers) {
81
133
  function member(value, values) {
82
134
  return typeof value === "string" && values.includes(value) ? value : undefined;
83
135
  }
84
- function endpoint(value) {
85
- if (!boundedNonempty(value, USER_STORE_LIMITS.endpoint))
86
- return undefined;
87
- try {
88
- return parseOllamaEndpoint(value).baseUrl;
89
- }
90
- catch {
91
- return undefined;
136
+ function legacyOllamaHost(value) {
137
+ if (isLegacyOllamaCloudHost(value))
138
+ return OLLAMA_CLOUD_HOST;
139
+ // Even an invalid retired value must not disappear and enable cloud use.
140
+ return boundedNonempty(value, USER_STORE_LIMITS.endpoint) ? value : "unsupported legacy Ollama endpoint";
141
+ }
142
+ function assertRetiredOllamaHost(value) {
143
+ if ("ollamaHost" in value && !isLegacyOllamaCloudHost(value.ollamaHost)) {
144
+ throw new Error("settings store has a retired Ollama endpoint; remove ollamaHost from settings.json to use Ollama API");
92
145
  }
93
146
  }
94
147
  function boundedNonempty(value, max) {
@@ -0,0 +1,148 @@
1
+ // Bounded directory enumeration with post-open identity and root checks.
2
+ import { lstat, opendir, realpath } from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { stableFileExpectation } from "./bounded-file.js";
5
+ import { fileIdentity, sameFileIdentity } from "./file-identity.js";
6
+ export class StableDirectoryError extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = "StableDirectoryError";
10
+ }
11
+ }
12
+ export async function readStableDirectory(root, directory, options) {
13
+ if (!Number.isSafeInteger(options.maxEntries) || options.maxEntries < 0) {
14
+ throw new RangeError("directory entry limit is invalid");
15
+ }
16
+ throwIfAborted(options.signal);
17
+ const canonicalRoot = await realpath(root);
18
+ const canonicalDirectory = await realpath(directory);
19
+ if (!inside(canonicalRoot, canonicalDirectory))
20
+ throw escaped();
21
+ const before = await lstat(canonicalDirectory, { bigint: true });
22
+ requireDirectory(before);
23
+ await options.beforeOpen?.();
24
+ throwIfAborted(options.signal);
25
+ const names = [];
26
+ let capped = false;
27
+ try {
28
+ const handle = await opendir(canonicalDirectory);
29
+ for await (const entry of handle) {
30
+ throwIfAborted(options.signal);
31
+ if (names.length >= options.maxEntries) {
32
+ capped = true;
33
+ break;
34
+ }
35
+ if (directName(entry.name))
36
+ names.push(entry.name);
37
+ }
38
+ }
39
+ catch (error) {
40
+ if (changedPath(error))
41
+ throw changed();
42
+ throw error;
43
+ }
44
+ await assertDirectory(canonicalRoot, canonicalDirectory, before);
45
+ const entries = [];
46
+ for (const name of names) {
47
+ throwIfAborted(options.signal);
48
+ const entry = await inspectEntry(canonicalRoot, canonicalDirectory, name);
49
+ if (entry !== undefined)
50
+ entries.push(entry);
51
+ }
52
+ await assertDirectory(canonicalRoot, canonicalDirectory, before);
53
+ entries.sort((left, right) => left.name.localeCompare(right.name));
54
+ return { entries, capped };
55
+ }
56
+ async function inspectEntry(root, directory, name) {
57
+ const target = path.join(directory, name);
58
+ let before;
59
+ try {
60
+ before = await lstat(target, { bigint: true });
61
+ }
62
+ catch (error) {
63
+ if (changedPath(error))
64
+ return undefined;
65
+ throw error;
66
+ }
67
+ if (before.isSymbolicLink())
68
+ return undefined;
69
+ const kind = before.isFile() ? "file" : before.isDirectory() ? "directory" : undefined;
70
+ if (kind === undefined)
71
+ return undefined;
72
+ let canonical;
73
+ try {
74
+ canonical = await realpath(target);
75
+ }
76
+ catch (error) {
77
+ if (changedPath(error))
78
+ return undefined;
79
+ throw error;
80
+ }
81
+ if (!inside(root, canonical) || !samePath(target, canonical))
82
+ return undefined;
83
+ let after;
84
+ try {
85
+ after = await lstat(target, { bigint: true });
86
+ }
87
+ catch (error) {
88
+ if (changedPath(error))
89
+ return undefined;
90
+ throw error;
91
+ }
92
+ if (after.isSymbolicLink() ||
93
+ !sameFileIdentity(fileIdentity(before), fileIdentity(after)) ||
94
+ (kind === "file" ? !after.isFile() : !after.isDirectory()))
95
+ return undefined;
96
+ return kind === "file"
97
+ ? { name, kind, expected: stableFileExpectation(after) }
98
+ : { name, kind };
99
+ }
100
+ async function assertDirectory(root, directory, expected) {
101
+ let current;
102
+ let canonical;
103
+ try {
104
+ [current, canonical] = await Promise.all([
105
+ lstat(directory, { bigint: true }),
106
+ realpath(directory),
107
+ ]);
108
+ }
109
+ catch (error) {
110
+ if (changedPath(error))
111
+ throw changed();
112
+ throw error;
113
+ }
114
+ requireDirectory(current);
115
+ if (!inside(root, canonical) || !samePath(directory, canonical) ||
116
+ !sameFileIdentity(fileIdentity(expected), fileIdentity(current)))
117
+ throw changed();
118
+ }
119
+ function requireDirectory(details) {
120
+ if (details.isSymbolicLink() || !details.isDirectory())
121
+ throw changed();
122
+ }
123
+ function directName(name) {
124
+ return name !== "." && name !== ".." && path.basename(name) === name;
125
+ }
126
+ function inside(root, target) {
127
+ const relative = path.relative(root, target);
128
+ return relative === "" ||
129
+ (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`));
130
+ }
131
+ function samePath(left, right) {
132
+ return path.relative(left, right) === "";
133
+ }
134
+ function changedPath(error) {
135
+ const code = error.code;
136
+ return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP";
137
+ }
138
+ function changed() {
139
+ return new StableDirectoryError("directory changed while it was being listed");
140
+ }
141
+ function escaped() {
142
+ return new StableDirectoryError("directory escapes the workspace root");
143
+ }
144
+ function throwIfAborted(signal) {
145
+ if (signal?.aborted !== true)
146
+ return;
147
+ throw signal.reason instanceof Error ? signal.reason : new Error("aborted");
148
+ }
package/dist/start.js CHANGED
@@ -6,7 +6,7 @@ import { loadConfig } from "./config.js";
6
6
  import { ConversationTree } from "./conversation.js";
7
7
  import { parseLaunch } from "./launch.js";
8
8
  import { systemPrompt } from "./prompt.js";
9
- import { configureProviders, selectProvider } from "./providers/index.js";
9
+ import { selectProvider } from "./providers/index.js";
10
10
  import { SessionPersistence } from "./sessions/runtime.js";
11
11
  import { DurableSessionStore } from "./sessions/store.js";
12
12
  import { builtinTools } from "./tools/index.js";
@@ -23,7 +23,6 @@ export async function start(args = process.argv.slice(2), environment = {}) {
23
23
  return;
24
24
  const launch = parseLaunch(args);
25
25
  const config = loadConfig(launch.configArgs);
26
- configureProviders(config);
27
26
  const provider = selectProvider(config.providerId);
28
27
  const hasScreen = environment.interactive?.() ?? interactive();
29
28
  if (launch.kind === "resume" && !hasScreen) {
@@ -3,121 +3,105 @@
3
3
  // Every writer rereads inside the lock. That prevents two Jecode processes
4
4
  // from replacing unrelated settings, API keys, or rotated OAuth credentials
5
5
  // with snapshots they cached before the other process wrote.
6
- import { randomUUID } from "node:crypto";
7
- import { mkdir, open, readFile, rmdir, stat, unlink } from "node:fs/promises";
8
6
  import * as path from "node:path";
7
+ import { lstat } from "node:fs/promises";
8
+ import { BoundedFileError, readBoundedText } from "./bounded-file.js";
9
+ import { pidIsAlive, ProcessLeaseError, processLeaseToken, tryAcquireProcessLease, } from "./process-lease.js";
9
10
  const WAIT_MS = 50;
10
11
  const WAIT_LIMIT_MS = 20_000;
11
12
  const STALE_MS = 60_000;
12
- export async function withStoreLock(file, body, signal) {
13
+ const LEGACY_OWNER_BYTES = 256;
14
+ export async function withStoreLock(file, body, signal, validate) {
13
15
  throwIfAborted(signal);
16
+ await validate?.();
14
17
  const directory = `${file}.lock`;
15
- const token = `${process.pid}:${randomUUID()}`;
18
+ const token = processLeaseToken();
16
19
  const started = Date.now();
17
- while (!(await acquire(directory, token))) {
20
+ let lease;
21
+ while (lease === undefined) {
22
+ await validate?.();
23
+ try {
24
+ lease = await tryAcquireProcessLease(directory, token, {
25
+ staleMs: STALE_MS,
26
+ setupGraceMs: 1_000,
27
+ });
28
+ }
29
+ catch (error) {
30
+ if (!(error instanceof ProcessLeaseError))
31
+ throw error;
32
+ throw await incompatibleLock(directory, file);
33
+ }
34
+ if (lease !== undefined)
35
+ break;
18
36
  if (signal?.aborted === true)
19
37
  throw abortReason(signal);
20
38
  if (Date.now() - started >= WAIT_LIMIT_MS) {
21
39
  throw new Error(`timed out waiting for ${path.basename(file)}`);
22
40
  }
23
- await recoverStale(directory);
24
41
  await wait(WAIT_MS, signal);
25
42
  }
43
+ let primaryFailure;
26
44
  try {
27
45
  throwIfAborted(signal);
46
+ await lease.assertOwned();
47
+ await validate?.();
28
48
  return await body();
29
49
  }
30
- finally {
31
- await release(directory, token);
50
+ catch (error) {
51
+ primaryFailure = error;
52
+ throw error;
32
53
  }
33
- }
34
- async function acquire(directory, token) {
35
- let created = false;
36
- try {
37
- await mkdir(directory, { mode: 0o700 });
38
- created = true;
39
- const owner = await open(path.join(directory, "owner"), "wx", 0o600);
54
+ finally {
40
55
  try {
41
- await owner.writeFile(token, "utf8");
42
- await owner.sync();
56
+ if (!(await lease.release()) && primaryFailure === undefined) {
57
+ throw new Error("store lock ownership was lost");
58
+ }
43
59
  }
44
- finally {
45
- await owner.close();
60
+ catch (error) {
61
+ if (primaryFailure === undefined)
62
+ throw error;
46
63
  }
47
- return true;
48
- }
49
- catch (error) {
50
- if (!created && error.code === "EEXIST")
51
- return false;
52
- if (created)
53
- await removeLock(directory);
54
- throw error;
55
64
  }
56
65
  }
57
- async function recoverStale(directory) {
58
- try {
59
- const details = await stat(directory);
60
- if (Date.now() - details.mtimeMs < STALE_MS)
61
- return;
62
- if (await ownerIsAlive(directory))
63
- return;
64
- // Remove the owner first, then the now-empty directory. `rmdir` cannot
65
- // erase a fresh lock acquired after another waiter wins this recovery,
66
- // whereas renaming the shared path can steal that new lock in an ABA race.
67
- await removeLock(directory);
68
- }
69
- catch (error) {
70
- const code = error.code;
71
- if (code !== "ENOENT" && code !== "EACCES" && code !== "EPERM")
72
- throw error;
66
+ async function incompatibleLock(directory, file) {
67
+ const legacy = await legacyOwner(directory);
68
+ if (legacy !== undefined) {
69
+ if (legacy.pid !== undefined && pidIsAlive(legacy.pid)) {
70
+ return new Error(`${path.basename(file)} is locked by an older Jecode process ` +
71
+ `(PID ${legacy.pid}) — close it and retry`);
72
+ }
73
+ return new Error(`${path.basename(file)} has an incompatible legacy lock — close older Jecode ` +
74
+ `processes, then remove this exact lock directory and retry: ${directory}`);
73
75
  }
76
+ return new Error(`${path.basename(file)} has an unsafe lock — inspect this exact directory before retrying: ` +
77
+ directory);
74
78
  }
75
- async function ownerIsAlive(directory) {
76
- let token;
77
- try {
78
- token = await readFile(path.join(directory, "owner"), "utf8");
79
- }
80
- catch (error) {
81
- const code = error.code;
82
- if (code === "ENOENT")
83
- return false;
84
- if (code === "EACCES" || code === "EPERM")
85
- return true;
86
- throw error;
87
- }
88
- const match = /^([1-9]\d*):/.exec(token.trim());
89
- if (match === null)
90
- return false;
91
- const pid = Number(match[1]);
92
- if (!Number.isSafeInteger(pid) || pid > 0x7fff_ffff)
93
- return false;
79
+ async function legacyOwner(directory) {
80
+ const owner = path.join(directory, "owner");
94
81
  try {
95
- process.kill(pid, 0);
96
- return true;
82
+ const details = await lstat(owner, { bigint: true });
83
+ if (details.isSymbolicLink() || !details.isFile() ||
84
+ details.size > BigInt(LEGACY_OWNER_BYTES))
85
+ return { pid: undefined };
86
+ const token = (await readBoundedText(owner, LEGACY_OWNER_BYTES, {
87
+ label: "legacy store lock",
88
+ })).trim();
89
+ const match = /^([1-9]\d*):/.exec(token);
90
+ if (match === null)
91
+ return { pid: undefined };
92
+ const pid = Number(match[1]);
93
+ return Number.isSafeInteger(pid) && pid <= 0x7fff_ffff
94
+ ? { pid }
95
+ : { pid: undefined };
97
96
  }
98
97
  catch (error) {
99
- // ESRCH is the one portable proof that the owner no longer exists.
100
- // Permission failures and unknown platform errors must not authorize a
101
- // second writer to enter the same store.
102
- return error.code !== "ESRCH";
98
+ if (error.code === "ENOENT")
99
+ return undefined;
100
+ if (error instanceof BoundedFileError)
101
+ return { pid: undefined };
102
+ return undefined;
103
103
  }
104
104
  }
105
- async function release(directory, token) {
106
- try {
107
- const owner = path.join(directory, "owner");
108
- if ((await readFile(owner, "utf8")) !== token)
109
- return;
110
- await unlink(owner);
111
- await rmdir(directory);
112
- }
113
- catch {
114
- // A recovered stale lock or an already-removed directory is no longer ours.
115
- }
116
- }
117
- async function removeLock(directory) {
118
- await unlink(path.join(directory, "owner")).catch(() => undefined);
119
- await rmdir(directory).catch(() => undefined);
120
- }
121
105
  function wait(ms, signal) {
122
106
  return new Promise((resolve, reject) => {
123
107
  if (signal?.aborted === true) {
@@ -24,8 +24,8 @@ export function optionalInt(args, name) {
24
24
  if (value === undefined || value === null)
25
25
  return undefined;
26
26
  const n = typeof value === "string" ? Number(value) : value;
27
- if (typeof n !== "number" || !Number.isInteger(n)) {
28
- throw new Error(`"${name}" must be an integer`);
27
+ if (typeof n !== "number" || !Number.isSafeInteger(n)) {
28
+ throw new Error(`"${name}" must be a safe integer`);
29
29
  }
30
30
  return n;
31
31
  }