@primitive.ai/prim 0.1.0-alpha.45 → 0.1.0-alpha.46

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.
@@ -1,44 +1,211 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/daemon/server.ts
4
- import { existsSync as existsSync5, readFileSync as readFileSync4, unlinkSync as unlinkSync3 } from "fs";
4
+ import { existsSync as existsSync6, readFileSync as readFileSync5, unlinkSync as unlinkSync3 } from "fs";
5
5
  import { createServer } from "net";
6
6
  import { homedir as homedir5 } from "os";
7
- import { join as join6 } from "path";
7
+ import { join as join7 } from "path";
8
8
  import { fileURLToPath } from "url";
9
9
 
10
10
  // src/client.ts
11
- import { existsSync, readFileSync, writeFileSync } from "fs";
11
+ import { createHash, randomBytes as randomBytes2 } from "crypto";
12
+ import {
13
+ chmodSync,
14
+ existsSync as existsSync2,
15
+ mkdirSync as mkdirSync2,
16
+ readFileSync as readFileSync2,
17
+ renameSync,
18
+ rmSync as rmSync2,
19
+ writeFileSync as writeFileSync2
20
+ } from "fs";
12
21
  import { homedir } from "os";
13
- import { join, resolve } from "path";
22
+ import { dirname as dirname2, join as join2, resolve } from "path";
23
+
24
+ // src/lib/file-lock.ts
25
+ import { randomBytes } from "crypto";
26
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
27
+ import { dirname, join } from "path";
28
+ var DEFAULT_TIMEOUT_MS = 3e4;
29
+ var DEFAULT_POLL_MS = 50;
30
+ var DEFAULT_INIT_GRACE_MS = 2e3;
31
+ var DIRECTORY_MODE = 448;
32
+ var FILE_MODE = 384;
33
+ function ownerPath(lockDir) {
34
+ return join(lockDir, "owner.json");
35
+ }
36
+ function readOwner(lockDir) {
37
+ try {
38
+ const owner = JSON.parse(readFileSync(ownerPath(lockDir), "utf8"));
39
+ if (!Number.isInteger(owner.pid) || owner.pid <= 0 || typeof owner.nonce !== "string" || typeof owner.createdAt !== "number") {
40
+ return null;
41
+ }
42
+ return owner;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+ function sameOwner(a, b) {
48
+ return a?.pid === b.pid && a.nonce === b.nonce && a.createdAt === b.createdAt;
49
+ }
50
+ function tryTakeLock(lockDir, now) {
51
+ try {
52
+ mkdirSync(lockDir, { mode: DIRECTORY_MODE });
53
+ } catch (error) {
54
+ if (error.code === "EEXIST") return null;
55
+ throw error;
56
+ }
57
+ const owner = {
58
+ pid: process.pid,
59
+ nonce: randomBytes(16).toString("hex"),
60
+ createdAt: now
61
+ };
62
+ try {
63
+ writeFileSync(ownerPath(lockDir), `${JSON.stringify(owner)}
64
+ `, {
65
+ encoding: "utf8",
66
+ mode: FILE_MODE,
67
+ flag: "wx"
68
+ });
69
+ return owner;
70
+ } catch (error) {
71
+ rmSync(lockDir, { recursive: true, force: true });
72
+ throw error;
73
+ }
74
+ }
75
+ function processIsAlive(pid) {
76
+ try {
77
+ process.kill(pid, 0);
78
+ return true;
79
+ } catch (error) {
80
+ return error.code === "EPERM";
81
+ }
82
+ }
83
+ function recoverStaleLock(lockDir, now, alive, initGraceMs) {
84
+ const owner = readOwner(lockDir);
85
+ if (owner) {
86
+ if (alive(owner.pid)) return false;
87
+ if (!sameOwner(readOwner(lockDir), owner)) return false;
88
+ rmSync(lockDir, { recursive: true, force: true });
89
+ return true;
90
+ }
91
+ try {
92
+ const before = statSync(lockDir).mtimeMs;
93
+ if (now - before < initGraceMs) return false;
94
+ if (readOwner(lockDir)) return false;
95
+ if (statSync(lockDir).mtimeMs !== before) return false;
96
+ rmSync(lockDir, { recursive: true, force: true });
97
+ return true;
98
+ } catch {
99
+ return true;
100
+ }
101
+ }
102
+ function abortError(signal) {
103
+ if (signal.reason instanceof Error) return signal.reason;
104
+ const error = new Error("The operation was aborted");
105
+ error.name = "AbortError";
106
+ return error;
107
+ }
108
+ function throwIfAborted(signal) {
109
+ if (signal?.aborted) throw abortError(signal);
110
+ }
111
+ async function defaultSleep(ms, signal) {
112
+ if (!signal) {
113
+ await new Promise((resolve2) => setTimeout(resolve2, ms));
114
+ return;
115
+ }
116
+ throwIfAborted(signal);
117
+ await new Promise((resolve2, reject) => {
118
+ const timer = setTimeout(() => {
119
+ signal.removeEventListener("abort", onAbort);
120
+ resolve2();
121
+ }, ms);
122
+ const onAbort = () => {
123
+ clearTimeout(timer);
124
+ reject(abortError(signal));
125
+ };
126
+ signal.addEventListener("abort", onAbort, { once: true });
127
+ });
128
+ }
129
+ async function withFileLock(lockDir, operation, options = {}) {
130
+ mkdirSync(dirname(lockDir), { recursive: true, mode: DIRECTORY_MODE });
131
+ const nowMs = options.nowMs ?? Date.now;
132
+ const alive = options.processAlive ?? processIsAlive;
133
+ const deadline = nowMs() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
134
+ let owner = null;
135
+ while (!owner) {
136
+ throwIfAborted(options.signal);
137
+ const now = nowMs();
138
+ owner = tryTakeLock(lockDir, now);
139
+ if (owner) break;
140
+ recoverStaleLock(lockDir, now, alive, options.initGraceMs ?? DEFAULT_INIT_GRACE_MS);
141
+ if (now >= deadline) {
142
+ throw new Error(`timed out waiting for file lock ${lockDir}`);
143
+ }
144
+ if (options.sleep) {
145
+ await options.sleep(options.pollMs ?? DEFAULT_POLL_MS);
146
+ throwIfAborted(options.signal);
147
+ } else {
148
+ await defaultSleep(options.pollMs ?? DEFAULT_POLL_MS, options.signal);
149
+ }
150
+ }
151
+ try {
152
+ throwIfAborted(options.signal);
153
+ return await operation();
154
+ } finally {
155
+ if (sameOwner(readOwner(lockDir), owner)) {
156
+ rmSync(lockDir, { recursive: true, force: true });
157
+ }
158
+ }
159
+ }
160
+
161
+ // src/client.ts
162
+ var CONFIG_DIR_MODE = 448;
163
+ var CREDENTIAL_FILE_MODE = 384;
164
+ var REFRESH_THRESHOLD_MS = 6e4;
165
+ var DEFAULT_API_URL = "https://api.getprimitive.ai";
14
166
  function loadEnvFile() {
15
167
  const envVars = {};
16
- const candidates = [".env.local", ".env"];
17
- for (const file of candidates) {
168
+ for (const file of [".env.local", ".env"]) {
18
169
  const filePath = resolve(process.cwd(), file);
19
- if (existsSync(filePath)) {
20
- const content = readFileSync(filePath, "utf-8");
21
- for (const line of content.split("\n")) {
22
- const trimmed = line.trim();
23
- if (!trimmed || trimmed.startsWith("#")) continue;
24
- const eqIdx = trimmed.indexOf("=");
25
- if (eqIdx === -1) continue;
26
- const key = trimmed.slice(0, eqIdx).trim();
27
- const value = trimmed.slice(eqIdx + 1).trim();
28
- envVars[key] = value;
29
- }
170
+ if (!existsSync2(filePath)) continue;
171
+ const content = readFileSync2(filePath, "utf-8");
172
+ for (const line of content.split("\n")) {
173
+ const trimmed = line.trim();
174
+ if (!trimmed || trimmed.startsWith("#")) continue;
175
+ const eqIdx = trimmed.indexOf("=");
176
+ if (eqIdx === -1) continue;
177
+ envVars[trimmed.slice(0, eqIdx).trim()] = trimmed.slice(eqIdx + 1).trim();
30
178
  }
31
179
  }
32
180
  return envVars;
33
181
  }
34
- var TOKEN_FILE_PATH = join(homedir(), ".config", "prim", "token");
35
- var REFRESH_TOKEN_PATH = TOKEN_FILE_PATH.replace("/token", "/refresh_token");
36
- var TOKEN_EXPIRES_PATH = join(homedir(), ".config", "prim", "token_expires_at");
37
- var REFRESH_THRESHOLD_MS = 6e4;
38
- function isTokenExpiringSoon() {
39
- if (!existsSync(TOKEN_EXPIRES_PATH)) return false;
40
- const expiresAt = Number(readFileSync(TOKEN_EXPIRES_PATH, "utf-8").trim());
41
- return !Number.isNaN(expiresAt) && Date.now() >= expiresAt - REFRESH_THRESHOLD_MS;
182
+ var TOKEN_FILE_PATH = join2(homedir(), ".config", "prim", "token");
183
+ var REFRESH_TOKEN_PATH = join2(dirname2(TOKEN_FILE_PATH), "refresh_token");
184
+ var TOKEN_EXPIRES_PATH = join2(dirname2(TOKEN_FILE_PATH), "token_expires_at");
185
+ var TERMINAL_REFRESH_PATH = join2(dirname2(TOKEN_FILE_PATH), "refresh_terminal");
186
+ var CREDENTIAL_LOCK_PATH = join2(dirname2(TOKEN_FILE_PATH), "credentials.lock");
187
+ function readTrimmed(path) {
188
+ try {
189
+ const value = readFileSync2(path, "utf8").trim();
190
+ return value || void 0;
191
+ } catch {
192
+ return void 0;
193
+ }
194
+ }
195
+ function resolveAuthCredential() {
196
+ if (process.env.PRIM_TOKEN) {
197
+ return { token: process.env.PRIM_TOKEN, source: "environment" };
198
+ }
199
+ const stored = readTrimmed(TOKEN_FILE_PATH);
200
+ if (stored) {
201
+ return { token: stored, source: "token_file" };
202
+ }
203
+ const envToken = loadEnvFile().PRIM_TOKEN;
204
+ return envToken ? { token: envToken, source: "env_file" } : void 0;
205
+ }
206
+ function getSiteUrl() {
207
+ if (process.env.PRIM_API_URL) return process.env.PRIM_API_URL;
208
+ return loadEnvFile().PRIM_API_URL ?? DEFAULT_API_URL;
42
209
  }
43
210
  function getJwtExpiry(token) {
44
211
  const parts = token.split(".");
@@ -50,112 +217,169 @@ function getJwtExpiry(token) {
50
217
  return void 0;
51
218
  }
52
219
  }
53
- function saveTokenExpiry(token, expiresIn) {
54
- const expiresAt = expiresIn ? Date.now() + expiresIn * 1e3 : getJwtExpiry(token);
55
- if (expiresAt) {
56
- writeFileSync(TOKEN_EXPIRES_PATH, String(expiresAt), { mode: 384 });
220
+ function expiresAtFor(token, expiresIn) {
221
+ return expiresIn === void 0 ? getJwtExpiry(token) : Date.now() + expiresIn * 1e3;
222
+ }
223
+ function ensureConfigDirectory() {
224
+ const directory = dirname2(TOKEN_FILE_PATH);
225
+ mkdirSync2(directory, { recursive: true, mode: CONFIG_DIR_MODE });
226
+ chmodSync(directory, CONFIG_DIR_MODE);
227
+ }
228
+ function atomicWrite(path, content) {
229
+ ensureConfigDirectory();
230
+ const temp = join2(
231
+ dirname2(path),
232
+ `.${path.slice(dirname2(path).length + 1)}.tmp-${process.pid}-${randomBytes2(8).toString("hex")}`
233
+ );
234
+ try {
235
+ writeFileSync2(temp, content, {
236
+ encoding: "utf8",
237
+ mode: CREDENTIAL_FILE_MODE,
238
+ flag: "wx"
239
+ });
240
+ renameSync(temp, path);
241
+ } catch (error) {
242
+ rmSync2(temp, { force: true });
243
+ throw error;
57
244
  }
58
245
  }
246
+ function removeCredentialFile(path) {
247
+ if (!existsSync2(path)) return false;
248
+ rmSync2(path, { force: true });
249
+ return true;
250
+ }
59
251
  function getTokenExpiresAt() {
60
- if (!existsSync(TOKEN_EXPIRES_PATH)) return void 0;
61
- const val = Number(readFileSync(TOKEN_EXPIRES_PATH, "utf-8").trim());
62
- return Number.isNaN(val) ? void 0 : val;
252
+ const stored = readTrimmed(TOKEN_EXPIRES_PATH);
253
+ if (stored === void 0) return void 0;
254
+ const value = Number(stored);
255
+ return Number.isNaN(value) ? void 0 : value;
63
256
  }
64
- function getAuthToken() {
65
- if (process.env.PRIM_TOKEN) {
66
- return process.env.PRIM_TOKEN;
67
- }
68
- if (existsSync(TOKEN_FILE_PATH)) {
69
- const token = readFileSync(TOKEN_FILE_PATH, "utf-8").trim();
70
- if (token) {
71
- return token;
72
- }
73
- }
74
- const envVars = loadEnvFile();
75
- if (envVars.PRIM_TOKEN) {
76
- return envVars.PRIM_TOKEN;
77
- }
78
- return void 0;
257
+ function isTokenExpiringSoon(credential) {
258
+ if (credential.source !== "token_file") return false;
259
+ const expiresAt = getTokenExpiresAt();
260
+ return expiresAt !== void 0 && Date.now() >= expiresAt - REFRESH_THRESHOLD_MS;
79
261
  }
80
- var DEFAULT_API_URL = "https://api.getprimitive.ai";
81
- function getSiteUrl() {
82
- if (process.env.PRIM_API_URL) {
83
- return process.env.PRIM_API_URL;
84
- }
85
- const envVars = loadEnvFile();
86
- if (envVars.PRIM_API_URL) {
87
- return envVars.PRIM_API_URL;
88
- }
89
- return DEFAULT_API_URL;
262
+ function refreshFingerprint(refreshToken2) {
263
+ return createHash("sha256").update(refreshToken2).digest("hex");
264
+ }
265
+ function terminalFingerprint() {
266
+ return readTrimmed(TERMINAL_REFRESH_PATH);
267
+ }
268
+ function writeTerminalFingerprint(refreshToken2) {
269
+ atomicWrite(TERMINAL_REFRESH_PATH, `${refreshFingerprint(refreshToken2)}
270
+ `);
271
+ }
272
+ function clearTerminalFingerprint() {
273
+ removeCredentialFile(TERMINAL_REFRESH_PATH);
90
274
  }
91
- var _endedRefreshToken;
92
275
  function isSessionEnded() {
93
- if (_endedRefreshToken === void 0) {
94
- return false;
95
- }
96
- if (!existsSync(REFRESH_TOKEN_PATH)) {
97
- return false;
98
- }
99
- const current = readFileSync(REFRESH_TOKEN_PATH, "utf-8").trim();
100
- return current === _endedRefreshToken;
276
+ if (resolveAuthCredential()?.source !== "token_file") return false;
277
+ const refreshToken2 = readTrimmed(REFRESH_TOKEN_PATH);
278
+ const ended = terminalFingerprint();
279
+ return Boolean(refreshToken2 && ended && refreshFingerprint(refreshToken2) === ended);
101
280
  }
102
- async function performTokenRefresh(options = {}) {
103
- if (!existsSync(REFRESH_TOKEN_PATH)) {
104
- return void 0;
105
- }
106
- const refreshTokenValue = readFileSync(REFRESH_TOKEN_PATH, "utf-8").trim();
107
- if (!refreshTokenValue) {
108
- return void 0;
281
+ function withCredentialLock(operation, options = {}) {
282
+ return withFileLock(CREDENTIAL_LOCK_PATH, operation, options);
283
+ }
284
+ function commitCredentialsUnlocked(credentials) {
285
+ const accessToken = credentials.accessToken.trim();
286
+ const refreshToken2 = credentials.refreshToken.trim();
287
+ if (!accessToken || !refreshToken2) {
288
+ throw new Error("OAuth credentials require both access and refresh tokens");
109
289
  }
110
- if (_endedRefreshToken !== void 0 && refreshTokenValue === _endedRefreshToken) {
111
- return void 0;
290
+ atomicWrite(REFRESH_TOKEN_PATH, `${refreshToken2}
291
+ `);
292
+ const expiresAt = expiresAtFor(accessToken, credentials.expiresIn);
293
+ if (expiresAt === void 0) removeCredentialFile(TOKEN_EXPIRES_PATH);
294
+ else atomicWrite(TOKEN_EXPIRES_PATH, `${expiresAt}
295
+ `);
296
+ atomicWrite(TOKEN_FILE_PATH, `${accessToken}
297
+ `);
298
+ clearTerminalFingerprint();
299
+ _cachedCredential = { token: accessToken, source: "token_file" };
300
+ }
301
+ function isTerminalRefreshResponse(response, detail) {
302
+ if (detail.includes("invalid_grant") || detail.includes("Session has already ended")) return true;
303
+ try {
304
+ const error = JSON.parse(detail).error;
305
+ return error === "invalid_grant" || response.status === 401 && error === "Invalid or expired refresh token";
306
+ } catch {
307
+ return response.status === 401 && detail.includes("Invalid or expired refresh token");
112
308
  }
113
- const siteUrl = getSiteUrl();
114
- const response = await fetch(`${siteUrl}/mcp/broker/refresh`, {
115
- method: "POST",
116
- headers: { "Content-Type": "application/json" },
117
- body: JSON.stringify({ refresh_token: refreshTokenValue }),
118
- signal: options.signal
119
- });
120
- if (!response.ok) {
121
- const detail = (await response.text().catch(() => "")).slice(0, 200);
122
- if (detail.includes("invalid_grant") || detail.includes("Session has already ended")) {
123
- _endedRefreshToken = refreshTokenValue;
124
- }
125
- if (!options.quiet) {
126
- process.stderr.write(
127
- `[prim] token refresh rejected by broker: ${response.status} ${response.statusText}${detail ? ` \u2014 ${detail}` : ""}
309
+ }
310
+ function refreshDiagnostic(response, detail, quiet) {
311
+ if (quiet) return;
312
+ process.stderr.write(
313
+ `[prim] token refresh rejected by broker: ${response.status} ${response.statusText}${detail ? ` \u2014 ${detail}` : ""}
128
314
  `
129
- );
130
- }
131
- return void 0;
132
- }
133
- const data = await response.json();
134
- if (!data.access_token) {
135
- return void 0;
136
- }
137
- _endedRefreshToken = void 0;
138
- writeFileSync(TOKEN_FILE_PATH, data.access_token, { mode: 384 });
139
- if (data.refresh_token) {
140
- writeFileSync(REFRESH_TOKEN_PATH, data.refresh_token, { mode: 384 });
141
- }
142
- saveTokenExpiry(data.access_token, data.expires_in);
143
- return data.access_token;
315
+ );
316
+ }
317
+ async function performTokenRefresh(options = {}) {
318
+ const selected = resolveAuthCredential();
319
+ if (selected?.source !== "token_file") return void 0;
320
+ const startingGeneration = readTrimmed(REFRESH_TOKEN_PATH);
321
+ if (!startingGeneration || isSessionEnded()) return void 0;
322
+ return withCredentialLock(
323
+ async () => {
324
+ const currentCredential = resolveAuthCredential();
325
+ if (currentCredential?.source !== "token_file") return void 0;
326
+ const currentGeneration = readTrimmed(REFRESH_TOKEN_PATH);
327
+ if (!currentGeneration) return void 0;
328
+ if (currentGeneration !== startingGeneration) {
329
+ return currentCredential.token;
330
+ }
331
+ if (isSessionEnded()) return void 0;
332
+ const response = await fetch(`${getSiteUrl()}/mcp/broker/refresh`, {
333
+ method: "POST",
334
+ headers: { "Content-Type": "application/json" },
335
+ body: JSON.stringify({ refresh_token: currentGeneration }),
336
+ signal: options.signal
337
+ });
338
+ if (!response.ok) {
339
+ const detail = (await response.text().catch(() => "")).slice(0, 200);
340
+ if (isTerminalRefreshResponse(response, detail) && readTrimmed(REFRESH_TOKEN_PATH) === currentGeneration) {
341
+ writeTerminalFingerprint(currentGeneration);
342
+ }
343
+ refreshDiagnostic(response, detail, options.quiet);
344
+ const winner = resolveAuthCredential();
345
+ return readTrimmed(REFRESH_TOKEN_PATH) !== currentGeneration && winner?.source === "token_file" ? winner.token : void 0;
346
+ }
347
+ let data;
348
+ try {
349
+ data = await response.json();
350
+ } catch {
351
+ if (readTrimmed(REFRESH_TOKEN_PATH) === currentGeneration) {
352
+ writeTerminalFingerprint(currentGeneration);
353
+ }
354
+ return void 0;
355
+ }
356
+ const record = typeof data === "object" && data !== null && !Array.isArray(data) ? data : void 0;
357
+ const accessToken = typeof record?.access_token === "string" ? record.access_token.trim() : "";
358
+ const replacementRefreshToken = typeof record?.refresh_token === "string" ? record.refresh_token.trim() : "";
359
+ if (!(accessToken && replacementRefreshToken) || replacementRefreshToken === currentGeneration) {
360
+ if (readTrimmed(REFRESH_TOKEN_PATH) === currentGeneration) {
361
+ writeTerminalFingerprint(currentGeneration);
362
+ }
363
+ return void 0;
364
+ }
365
+ commitCredentialsUnlocked({
366
+ accessToken,
367
+ refreshToken: replacementRefreshToken,
368
+ expiresIn: typeof record?.expires_in === "number" && Number.isFinite(record.expires_in) ? record.expires_in : void 0
369
+ });
370
+ return accessToken;
371
+ },
372
+ { signal: options.signal }
373
+ );
144
374
  }
145
375
  var _refreshInFlight;
146
376
  function refreshToken(options = {}) {
147
- if (_refreshInFlight) {
148
- return _refreshInFlight;
149
- }
377
+ if (_refreshInFlight) return _refreshInFlight;
150
378
  const attempt = performTokenRefresh(options).then((token) => {
151
- if (token) {
152
- _cachedToken = token;
153
- }
379
+ if (token) _cachedCredential = { token, source: "token_file" };
154
380
  return token;
155
381
  }).finally(() => {
156
- if (_refreshInFlight === attempt) {
157
- _refreshInFlight = void 0;
158
- }
382
+ if (_refreshInFlight === attempt) _refreshInFlight = void 0;
159
383
  });
160
384
  _refreshInFlight = attempt;
161
385
  return attempt;
@@ -168,29 +392,29 @@ var HttpError = class extends Error {
168
392
  this.status = status;
169
393
  }
170
394
  };
171
- var _cachedToken;
172
- async function request(method, path, body, options) {
173
- const siteUrl = getSiteUrl();
174
- const url = `${siteUrl}${path}`;
175
- if (!_cachedToken) {
176
- _cachedToken = getAuthToken();
395
+ var _cachedCredential;
396
+ function selectedCredential() {
397
+ const resolved = resolveAuthCredential();
398
+ if (!_cachedCredential || resolved?.token !== _cachedCredential.token || resolved?.source !== _cachedCredential.source) {
399
+ _cachedCredential = resolved;
177
400
  }
178
- if (_cachedToken && isTokenExpiringSoon()) {
179
- const newToken = await refreshToken({
401
+ return _cachedCredential;
402
+ }
403
+ async function request(method, path, body, options) {
404
+ const url = `${getSiteUrl()}${path}`;
405
+ let credential = selectedCredential();
406
+ let refreshAttempted = false;
407
+ if (credential && isTokenExpiringSoon(credential)) {
408
+ refreshAttempted = true;
409
+ const token = await refreshToken({
180
410
  signal: options?.signal,
181
411
  quiet: options?.quietRefresh
182
412
  });
183
- if (newToken) {
184
- _cachedToken = newToken;
185
- }
413
+ if (token) credential = { token, source: "token_file" };
186
414
  }
187
- const doFetch = async (token) => {
188
- const headers = {
189
- "Content-Type": "application/json"
190
- };
191
- if (token) {
192
- headers.Authorization = `Bearer ${token}`;
193
- }
415
+ const doFetch = (token) => {
416
+ const headers = { "Content-Type": "application/json" };
417
+ if (token) headers.Authorization = `Bearer ${token}`;
194
418
  return fetch(url, {
195
419
  method,
196
420
  headers,
@@ -198,26 +422,31 @@ async function request(method, path, body, options) {
198
422
  signal: options?.signal
199
423
  });
200
424
  };
201
- const tokenUsed = _cachedToken;
202
- let res = await doFetch(tokenUsed);
203
- if (res.status === 401) {
204
- const newToken = _cachedToken && _cachedToken !== tokenUsed ? _cachedToken : await refreshToken({
205
- signal: options?.signal,
206
- quiet: options?.quietRefresh
207
- });
208
- if (newToken) {
209
- _cachedToken = newToken;
210
- res = await doFetch(newToken);
425
+ const tokenUsed = credential?.token;
426
+ let response = await doFetch(tokenUsed);
427
+ if (response.status === 401) {
428
+ const latest = resolveAuthCredential();
429
+ let retryToken = latest?.token !== tokenUsed ? latest?.token : void 0;
430
+ if (!retryToken && latest?.source === "token_file" && !refreshAttempted) {
431
+ refreshAttempted = true;
432
+ retryToken = await refreshToken({ signal: options?.signal, quiet: options?.quietRefresh });
433
+ }
434
+ if (retryToken) {
435
+ _cachedCredential = {
436
+ token: retryToken,
437
+ source: latest?.source === "token_file" ? "token_file" : latest?.source ?? "token_file"
438
+ };
439
+ response = await doFetch(retryToken);
211
440
  }
212
441
  }
213
- if (!res.ok) {
214
- if (res.status === 401) {
442
+ if (!response.ok) {
443
+ if (response.status === 401) {
215
444
  throw new HttpError(401, "Authentication expired. Run `prim auth login` to re-authenticate.");
216
445
  }
217
- const errorBody = await res.json().catch(() => null);
218
- throw new HttpError(res.status, errorBody?.error ?? `HTTP ${res.status}`);
446
+ const errorBody = await response.json().catch(() => null);
447
+ throw new HttpError(response.status, errorBody?.error ?? `HTTP ${response.status}`);
219
448
  }
220
- return res.json();
449
+ return response.json();
221
450
  }
222
451
  function getClient() {
223
452
  return {
@@ -227,7 +456,7 @@ function getClient() {
227
456
  }
228
457
 
229
458
  // src/flusher.ts
230
- import { createReadStream, renameSync, unlinkSync } from "fs";
459
+ import { createReadStream, renameSync as renameSync2, unlinkSync } from "fs";
231
460
  import { createInterface } from "readline";
232
461
 
233
462
  // src/ingest-response.ts
@@ -257,18 +486,18 @@ function requireDurableIngestAcknowledgement(value, expected) {
257
486
  import {
258
487
  appendFileSync,
259
488
  closeSync,
260
- existsSync as existsSync2,
489
+ existsSync as existsSync3,
261
490
  fstatSync,
262
- mkdirSync,
491
+ mkdirSync as mkdirSync3,
263
492
  openSync,
264
493
  opendirSync,
265
- readFileSync as readFileSync2,
494
+ readFileSync as readFileSync3,
266
495
  readSync,
267
496
  readdirSync
268
497
  } from "fs";
269
498
  import { homedir as homedir2 } from "os";
270
- import { dirname, join as join2 } from "path";
271
- var JOURNAL_DIR = join2(homedir2(), ".config", "prim", "moves");
499
+ import { dirname as dirname3, join as join3 } from "path";
500
+ var JOURNAL_DIR = join3(homedir2(), ".config", "prim", "moves");
272
501
  var JOURNAL_BASENAME = "journal.ndjson";
273
502
  var JOURNAL_STATS_SAMPLE_BYTES = 64 * 1024;
274
503
  var PENDING_STATS_MAX_FILES = 128;
@@ -280,7 +509,7 @@ function envSlug(apiUrl) {
280
509
  return slug;
281
510
  }
282
511
  function currentEnvDir() {
283
- return join2(JOURNAL_DIR, envSlug(getSiteUrl()));
512
+ return join3(JOURNAL_DIR, envSlug(getSiteUrl()));
284
513
  }
285
514
  function parseMoves(content) {
286
515
  const moves = [];
@@ -298,15 +527,15 @@ function parseMoves(content) {
298
527
  function listBuckets() {
299
528
  const out = [];
300
529
  const envDir = currentEnvDir();
301
- if (!existsSync2(envDir)) {
530
+ if (!existsSync3(envDir)) {
302
531
  return out;
303
532
  }
304
533
  for (const entry of readdirSync(envDir, { withFileTypes: true })) {
305
534
  if (!entry.isDirectory()) {
306
535
  continue;
307
536
  }
308
- const path = join2(envDir, entry.name, JOURNAL_BASENAME);
309
- if (existsSync2(path)) {
537
+ const path = join3(envDir, entry.name, JOURNAL_BASENAME);
538
+ if (existsSync3(path)) {
310
539
  out.push({ bucket: entry.name, path });
311
540
  }
312
541
  }
@@ -364,7 +593,7 @@ function parseFlushingPid(name) {
364
593
  return last !== void 0 && /^[0-9]+$/.test(last) ? Number(last) : void 0;
365
594
  }
366
595
  function listFlushingInDir(dir, bucket, options = {}) {
367
- if (!existsSync2(dir)) {
596
+ if (!existsSync3(dir)) {
368
597
  return [];
369
598
  }
370
599
  const out = [];
@@ -378,7 +607,7 @@ function listFlushingInDir(dir, bucket, options = {}) {
378
607
  entry = entries.readSync();
379
608
  continue;
380
609
  }
381
- const path = join2(dir, entry.name);
610
+ const path = join3(dir, entry.name);
382
611
  let sample;
383
612
  try {
384
613
  sample = sampleJournalFile(path, remainingBytes);
@@ -405,7 +634,7 @@ function listFlushingInDir(dir, bucket, options = {}) {
405
634
  }
406
635
  function listFlushing(options = {}) {
407
636
  const envDir = currentEnvDir();
408
- if (!existsSync2(envDir)) {
637
+ if (!existsSync3(envDir)) {
409
638
  return [];
410
639
  }
411
640
  const out = [];
@@ -416,7 +645,7 @@ function listFlushing(options = {}) {
416
645
  break;
417
646
  }
418
647
  if (entry.isDirectory()) {
419
- const found = listFlushingInDir(join2(envDir, entry.name), entry.name, {
648
+ const found = listFlushingInDir(join3(envDir, entry.name), entry.name, {
420
649
  sampleBytes: remainingBytes,
421
650
  maxFiles: maxFiles - out.length
422
651
  });
@@ -520,7 +749,7 @@ async function drainFlushingPath(flushingPath, client2 = getClient()) {
520
749
  async function drainPath(path) {
521
750
  const tmpPath = `${path}.flushing.${String(Date.now())}.${String(process.pid)}`;
522
751
  try {
523
- renameSync(path, tmpPath);
752
+ renameSync2(path, tmpPath);
524
753
  } catch (err) {
525
754
  if (err.code === "ENOENT") {
526
755
  return 0;
@@ -529,7 +758,7 @@ async function drainPath(path) {
529
758
  }
530
759
  return drainFlushingPath(tmpPath);
531
760
  }
532
- function processIsAlive(pid) {
761
+ function processIsAlive2(pid) {
533
762
  try {
534
763
  process.kill(pid, 0);
535
764
  return true;
@@ -539,7 +768,7 @@ function processIsAlive(pid) {
539
768
  }
540
769
  function selectRecoverable(files, now, opts = {}) {
541
770
  const quarantineMs = opts.quarantineMs ?? ORPHAN_QUARANTINE_MS;
542
- const isAlive = opts.isAlive ?? processIsAlive;
771
+ const isAlive = opts.isAlive ?? processIsAlive2;
543
772
  return files.filter((f) => {
544
773
  if (f.pid === void 0) {
545
774
  return now - f.mtimeMs > quarantineMs;
@@ -611,14 +840,14 @@ function flush() {
611
840
  // src/daemon/client.ts
612
841
  import { createConnection } from "net";
613
842
  import { homedir as homedir3 } from "os";
614
- import { join as join3 } from "path";
615
- var SOCK_PATH = join3(homedir3(), ".config", "prim", "sock");
616
- var DEFAULT_TIMEOUT_MS = 250;
843
+ import { join as join4 } from "path";
844
+ var SOCK_PATH = join4(homedir3(), ".config", "prim", "sock");
845
+ var DEFAULT_TIMEOUT_MS2 = 250;
617
846
  var nextRequestId = 1;
618
847
  function daemonRequest(method, params = {}, opts = {}) {
619
848
  const id = nextRequestId++;
620
849
  const request2 = { id, method, params };
621
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
850
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
622
851
  return new Promise((resolve2) => {
623
852
  let settled = false;
624
853
  let socket;
@@ -705,17 +934,17 @@ function assertCallerEnvMatches(callerEnv, boundEnv) {
705
934
  }
706
935
 
707
936
  // src/daemon/health.ts
708
- import { chmodSync, existsSync as existsSync3, mkdirSync as mkdirSync2, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "fs";
937
+ import { chmodSync as chmodSync2, existsSync as existsSync4, mkdirSync as mkdirSync4, renameSync as renameSync3, writeFileSync as writeFileSync3 } from "fs";
709
938
  import { homedir as homedir4 } from "os";
710
- import { dirname as dirname2, join as join4 } from "path";
711
- var CONFIG_DIR_MODE = 448;
939
+ import { dirname as dirname4, join as join5 } from "path";
940
+ var CONFIG_DIR_MODE2 = 448;
712
941
  var HEALTH_FILE_MODE = 384;
713
942
  var INGESTION_SLA_MS = 3e4;
714
943
  var HEARTBEAT_FRESH_MS = 9e4;
715
944
  var HEARTBEAT_RETRY_CAP_MS = 3e4;
716
945
  var INGESTION_RETRY_BASE_MS = 5e3;
717
946
  var INGESTION_RETRY_CAP_MS = 15e3;
718
- var DAEMON_HEALTH_PATH = join4(homedir4(), ".config", "prim", "daemon-health.json");
947
+ var DAEMON_HEALTH_PATH = join5(homedir4(), ".config", "prim", "daemon-health.json");
719
948
  function createDaemonHealthState(version, pid, startedAt2) {
720
949
  return {
721
950
  schemaVersion: 1,
@@ -753,37 +982,37 @@ function ingestionRetryDelayMs(consecutiveFailures, random = Math.random) {
753
982
  return retryDelayMs(consecutiveFailures, INGESTION_RETRY_CAP_MS, random);
754
983
  }
755
984
  function writeDaemonHealthState(state, path = DAEMON_HEALTH_PATH) {
756
- const dir = dirname2(path);
757
- if (!existsSync3(dir)) {
758
- mkdirSync2(dir, { recursive: true, mode: CONFIG_DIR_MODE });
985
+ const dir = dirname4(path);
986
+ if (!existsSync4(dir)) {
987
+ mkdirSync4(dir, { recursive: true, mode: CONFIG_DIR_MODE2 });
759
988
  }
760
- const tmp = join4(dir, `.${process.pid}.daemon-health.tmp`);
761
- writeFileSync2(tmp, `${JSON.stringify(state, null, 2)}
989
+ const tmp = join5(dir, `.${process.pid}.daemon-health.tmp`);
990
+ writeFileSync3(tmp, `${JSON.stringify(state, null, 2)}
762
991
  `, { mode: HEALTH_FILE_MODE });
763
- chmodSync(tmp, HEALTH_FILE_MODE);
764
- renameSync2(tmp, path);
992
+ chmodSync2(tmp, HEALTH_FILE_MODE);
993
+ renameSync3(tmp, path);
765
994
  }
766
995
 
767
996
  // src/daemon/instance-lock.ts
768
997
  import { randomUUID } from "crypto";
769
998
  import {
770
- chmodSync as chmodSync2,
771
- existsSync as existsSync4,
772
- mkdirSync as mkdirSync3,
773
- readFileSync as readFileSync3,
774
- renameSync as renameSync3,
775
- rmSync,
776
- statSync,
999
+ chmodSync as chmodSync3,
1000
+ existsSync as existsSync5,
1001
+ mkdirSync as mkdirSync5,
1002
+ readFileSync as readFileSync4,
1003
+ renameSync as renameSync4,
1004
+ rmSync as rmSync3,
1005
+ statSync as statSync2,
777
1006
  unlinkSync as unlinkSync2,
778
- writeFileSync as writeFileSync3
1007
+ writeFileSync as writeFileSync4
779
1008
  } from "fs";
780
- import { join as join5 } from "path";
1009
+ import { join as join6 } from "path";
781
1010
  var DIR_MODE = 448;
782
- var FILE_MODE = 384;
1011
+ var FILE_MODE2 = 384;
783
1012
  var DAEMON_STARTUP_GRACE_MS = 5e3;
784
1013
  function readLockRecord(path) {
785
1014
  try {
786
- const value = JSON.parse(readFileSync3(path, "utf-8"));
1015
+ const value = JSON.parse(readFileSync4(path, "utf-8"));
787
1016
  if (Number.isInteger(value.pid) && typeof value.instanceId === "string" && typeof value.startedAt === "number") {
788
1017
  return value;
789
1018
  }
@@ -793,7 +1022,7 @@ function readLockRecord(path) {
793
1022
  }
794
1023
  function numericPid(path) {
795
1024
  try {
796
- const pid = Number(readFileSync3(path, "utf-8").trim());
1025
+ const pid = Number(readFileSync4(path, "utf-8").trim());
797
1026
  return Number.isInteger(pid) && pid > 0 ? pid : void 0;
798
1027
  } catch {
799
1028
  return;
@@ -808,16 +1037,16 @@ function daemonProcessIsAlive(pid) {
808
1037
  }
809
1038
  }
810
1039
  function readDaemonOwner(configDir) {
811
- return readLockRecord(join5(configDir, "daemon.lock", "owner.json"));
1040
+ return readLockRecord(join6(configDir, "daemon.lock", "owner.json"));
812
1041
  }
813
1042
  function readDaemonPid(configDir) {
814
- const path = join5(configDir, "daemon.pid");
1043
+ const path = join6(configDir, "daemon.pid");
815
1044
  const pid = numericPid(path);
816
1045
  if (pid === void 0) {
817
1046
  return;
818
1047
  }
819
1048
  try {
820
- return { pid, mtimeMs: statSync(path).mtimeMs };
1049
+ return { pid, mtimeMs: statSync2(path).mtimeMs };
821
1050
  } catch {
822
1051
  return;
823
1052
  }
@@ -826,29 +1055,29 @@ function acquireDaemonOwnership(configDir, opts = {}) {
826
1055
  const pid = opts.pid ?? process.pid;
827
1056
  const now = opts.now ?? Date.now();
828
1057
  const isAlive = opts.isAlive ?? daemonProcessIsAlive;
829
- const lockDir = join5(configDir, "daemon.lock");
830
- const ownerPath = join5(lockDir, "owner.json");
831
- const pidPath = join5(configDir, "daemon.pid");
1058
+ const lockDir = join6(configDir, "daemon.lock");
1059
+ const ownerPath2 = join6(lockDir, "owner.json");
1060
+ const pidPath = join6(configDir, "daemon.pid");
832
1061
  const instanceId = randomUUID();
833
- if (!existsSync4(configDir)) {
834
- mkdirSync3(configDir, { recursive: true, mode: DIR_MODE });
1062
+ if (!existsSync5(configDir)) {
1063
+ mkdirSync5(configDir, { recursive: true, mode: DIR_MODE });
835
1064
  }
836
1065
  for (; ; ) {
837
1066
  try {
838
- mkdirSync3(lockDir, { mode: DIR_MODE });
1067
+ mkdirSync5(lockDir, { mode: DIR_MODE });
839
1068
  break;
840
1069
  } catch (err) {
841
1070
  if (err.code !== "EEXIST") {
842
1071
  throw err;
843
1072
  }
844
- const existing = readLockRecord(ownerPath);
1073
+ const existing = readLockRecord(ownerPath2);
845
1074
  if (existing && isAlive(existing.pid)) {
846
1075
  throw new Error(`daemon already running (pid=${String(existing.pid)})`);
847
1076
  }
848
1077
  if (!existing) {
849
1078
  let mtimeMs;
850
1079
  try {
851
- mtimeMs = statSync(lockDir).mtimeMs;
1080
+ mtimeMs = statSync2(lockDir).mtimeMs;
852
1081
  } catch (statErr) {
853
1082
  if (statErr.code === "ENOENT") {
854
1083
  continue;
@@ -861,8 +1090,8 @@ function acquireDaemonOwnership(configDir, opts = {}) {
861
1090
  }
862
1091
  const stale = `${lockDir}.stale.${String(pid)}.${instanceId}`;
863
1092
  try {
864
- renameSync3(lockDir, stale);
865
- rmSync(stale, { recursive: true, force: true });
1093
+ renameSync4(lockDir, stale);
1094
+ rmSync3(stale, { recursive: true, force: true });
866
1095
  } catch (renameErr) {
867
1096
  if (renameErr.code !== "ENOENT") {
868
1097
  throw renameErr;
@@ -873,22 +1102,22 @@ function acquireDaemonOwnership(configDir, opts = {}) {
873
1102
  const ownership2 = {
874
1103
  configDir,
875
1104
  lockDir,
876
- ownerPath,
1105
+ ownerPath: ownerPath2,
877
1106
  pidPath,
878
1107
  pid,
879
1108
  instanceId
880
1109
  };
881
1110
  const record = { pid, instanceId, startedAt: now };
882
- writeFileSync3(ownerPath, `${JSON.stringify(record)}
883
- `, { mode: FILE_MODE });
884
- chmodSync2(ownerPath, FILE_MODE);
1111
+ writeFileSync4(ownerPath2, `${JSON.stringify(record)}
1112
+ `, { mode: FILE_MODE2 });
1113
+ chmodSync3(ownerPath2, FILE_MODE2);
885
1114
  const legacyPid = numericPid(pidPath);
886
1115
  if (legacyPid !== void 0 && legacyPid !== pid && isAlive(legacyPid)) {
887
- rmSync(lockDir, { recursive: true, force: true });
1116
+ rmSync3(lockDir, { recursive: true, force: true });
888
1117
  throw new Error(`daemon already running (pid=${String(legacyPid)})`);
889
1118
  }
890
- writeFileSync3(pidPath, String(pid), { mode: FILE_MODE });
891
- chmodSync2(pidPath, FILE_MODE);
1119
+ writeFileSync4(pidPath, String(pid), { mode: FILE_MODE2 });
1120
+ chmodSync3(pidPath, FILE_MODE2);
892
1121
  return ownership2;
893
1122
  }
894
1123
  function ownsDaemonInstance(ownership2) {
@@ -918,7 +1147,7 @@ function releaseDaemonOwnership(ownership2, socketPath) {
918
1147
  }
919
1148
  }
920
1149
  }
921
- rmSync(ownership2.lockDir, { recursive: true, force: true });
1150
+ rmSync3(ownership2.lockDir, { recursive: true, force: true });
922
1151
  if (cleanupError) {
923
1152
  throw cleanupError;
924
1153
  }
@@ -926,8 +1155,8 @@ function releaseDaemonOwnership(ownership2, socketPath) {
926
1155
  }
927
1156
 
928
1157
  // src/daemon/server.ts
929
- var CONFIG_DIR = join6(homedir5(), ".config", "prim");
930
- var SOCK_PATH2 = join6(CONFIG_DIR, "sock");
1158
+ var CONFIG_DIR = join7(homedir5(), ".config", "prim");
1159
+ var SOCK_PATH2 = join7(CONFIG_DIR, "sock");
931
1160
  var HEARTBEAT_INTERVAL_MS = 3e4;
932
1161
  var INGESTION_POLL_INTERVAL_MS = 15e3;
933
1162
  var TOKEN_CHECK_INTERVAL_MS = 6e4;
@@ -961,7 +1190,7 @@ function resolveRuntimeVersion() {
961
1190
  }
962
1191
  try {
963
1192
  const here = fileURLToPath(new URL(".", import.meta.url));
964
- const pkg = JSON.parse(readFileSync4(join6(here, "..", "..", "package.json"), "utf-8"));
1193
+ const pkg = JSON.parse(readFileSync5(join7(here, "..", "..", "package.json"), "utf-8"));
965
1194
  return pkg.version ?? "unknown";
966
1195
  } catch {
967
1196
  return "unknown";
@@ -1032,7 +1261,7 @@ async function takeOwnership() {
1032
1261
  (pid) => pid !== void 0 && daemonProcessIsAlive(pid)
1033
1262
  )
1034
1263
  );
1035
- if (existsSync5(SOCK_PATH2) || candidatePids.size > 0) {
1264
+ if (existsSync6(SOCK_PATH2) || candidatePids.size > 0) {
1036
1265
  const snapshot = await daemonRequest(
1037
1266
  "status_snapshot",
1038
1267
  {},
@@ -1116,6 +1345,9 @@ async function performHeartbeat() {
1116
1345
  }
1117
1346
  }
1118
1347
  function sendHeartbeat() {
1348
+ if (shuttingDown || reauthHold) {
1349
+ return Promise.resolve();
1350
+ }
1119
1351
  if (heartbeatInFlight) {
1120
1352
  heartbeatRerunRequested = true;
1121
1353
  return heartbeatInFlight;
@@ -1128,7 +1360,7 @@ function sendHeartbeat() {
1128
1360
  do {
1129
1361
  heartbeatRerunRequested = false;
1130
1362
  await performHeartbeat();
1131
- } while (heartbeatRerunRequested && !shuttingDown);
1363
+ } while (heartbeatRerunRequested && !(shuttingDown || reauthHold));
1132
1364
  })().finally(() => {
1133
1365
  heartbeatInFlight = void 0;
1134
1366
  scheduleHeartbeat();
@@ -1149,6 +1381,9 @@ function scheduleHeartbeat() {
1149
1381
  }, delay);
1150
1382
  }
1151
1383
  async function ensureTokenFresh() {
1384
+ if (resolveAuthCredential()?.source !== "token_file") {
1385
+ return;
1386
+ }
1152
1387
  const expiresAt = getTokenExpiresAt();
1153
1388
  if (!expiresAt) {
1154
1389
  return;
@@ -1397,14 +1632,18 @@ function startSocketServer() {
1397
1632
  });
1398
1633
  }
1399
1634
  function startTimers() {
1400
- persistHealth();
1401
- void sendHeartbeat();
1402
- void runIngestionLoop();
1635
+ if (isSessionEnded()) {
1636
+ enterReauthHold();
1637
+ } else {
1638
+ persistHealth();
1639
+ void sendHeartbeat();
1640
+ void runIngestionLoop();
1641
+ }
1403
1642
  void runTokenCheckLoop();
1404
1643
  }
1405
1644
  async function runTokenCheckLoop() {
1406
1645
  if (reauthHold) {
1407
- if (!isSessionEnded()) {
1646
+ if (!isSessionEnded() && resolveAuthCredential()) {
1408
1647
  exitReauthHold();
1409
1648
  }
1410
1649
  } else {