logwork-cli 0.0.0-stage → 1.1.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 (96) hide show
  1. package/LICENSE +11 -0
  2. package/README.md +330 -2
  3. package/RELEASE.md +89 -0
  4. package/bin/logwork.mjs +104 -0
  5. package/commands/auth.mjs +227 -0
  6. package/commands/commits.mjs +82 -0
  7. package/commands/delete.mjs +69 -0
  8. package/commands/doctor.mjs +24 -0
  9. package/commands/edit.mjs +88 -0
  10. package/commands/history.mjs +62 -0
  11. package/commands/query.mjs +52 -0
  12. package/commands/quick.mjs +163 -0
  13. package/commands/reconcile.mjs +55 -0
  14. package/commands/release.mjs +77 -0
  15. package/commands/reminder.mjs +172 -0
  16. package/commands/setup.mjs +508 -0
  17. package/commands/skill.mjs +91 -0
  18. package/commands/tui.mjs +50 -0
  19. package/commands/update.mjs +161 -0
  20. package/docs/advanced.md +334 -0
  21. package/docs/mcp-setup.md +368 -0
  22. package/docs/operations-guide.md +587 -0
  23. package/docs/recipes.md +259 -0
  24. package/docs/security.md +151 -0
  25. package/examples/mcp/antigravity.mcp_config.json +8 -0
  26. package/examples/mcp/codex.config.toml +5 -0
  27. package/examples/mcp/copilot.mcp.json +9 -0
  28. package/lib/agent-skill.mjs +16 -0
  29. package/lib/api-auth.mjs +1259 -0
  30. package/lib/api.mjs +1422 -0
  31. package/lib/apply-ledger.mjs +227 -0
  32. package/lib/atomic-file.mjs +24 -0
  33. package/lib/auth-diagnostics.mjs +113 -0
  34. package/lib/auth-errors.mjs +22 -0
  35. package/lib/auth-redaction.mjs +78 -0
  36. package/lib/auth-terminal.mjs +97 -0
  37. package/lib/auth.mjs +323 -0
  38. package/lib/batch-parser.mjs +145 -0
  39. package/lib/batch-workflow.mjs +842 -0
  40. package/lib/commit-workflow.mjs +402 -0
  41. package/lib/config.mjs +191 -0
  42. package/lib/credential-store.mjs +130 -0
  43. package/lib/diagnostics.mjs +307 -0
  44. package/lib/entrypoint.mjs +14 -0
  45. package/lib/file-lock.mjs +44 -0
  46. package/lib/http.mjs +125 -0
  47. package/lib/jira-api.mjs +258 -0
  48. package/lib/jira-auth.mjs +203 -0
  49. package/lib/jira-workflow.mjs +523 -0
  50. package/lib/keychain.mjs +105 -0
  51. package/lib/legacy-migration.mjs +66 -0
  52. package/lib/list-navigation.mjs +19 -0
  53. package/lib/logwork-config.mjs +199 -0
  54. package/lib/logwork-f03-enrich.mjs +41 -0
  55. package/lib/macos.mjs +33 -0
  56. package/lib/manual-apply-state.mjs +35 -0
  57. package/lib/manual-commands.mjs +294 -0
  58. package/lib/manual-drafts.mjs +154 -0
  59. package/lib/manual-ink-app.mjs +1654 -0
  60. package/lib/manual-ink-auth.mjs +349 -0
  61. package/lib/manual-ink-ui.mjs +675 -0
  62. package/lib/manual-input.mjs +21 -0
  63. package/lib/manual-logwork-wizard.mjs +310 -0
  64. package/lib/manual-mcp.mjs +187 -0
  65. package/lib/manual-repl.mjs +1037 -0
  66. package/lib/manual-targets.mjs +78 -0
  67. package/lib/mcp-server.mjs +833 -0
  68. package/lib/os-notification.mjs +105 -0
  69. package/lib/package-info.mjs +17 -0
  70. package/lib/paths.mjs +68 -0
  71. package/lib/project-identity.mjs +66 -0
  72. package/lib/project-mapping-workflow.mjs +309 -0
  73. package/lib/project-resolver.mjs +373 -0
  74. package/lib/query-workflow.mjs +553 -0
  75. package/lib/reconciliation-workflow.mjs +446 -0
  76. package/lib/release-doctor.mjs +448 -0
  77. package/lib/reminder-config.mjs +71 -0
  78. package/lib/reminder-scheduler.mjs +301 -0
  79. package/lib/reminder-service.mjs +118 -0
  80. package/lib/reminder-state.mjs +38 -0
  81. package/lib/reminder-workflow.mjs +299 -0
  82. package/lib/ro-delete-args.mjs +38 -0
  83. package/lib/ro-delete-workflow.mjs +115 -0
  84. package/lib/ro-edit-args.mjs +142 -0
  85. package/lib/ro-edit-workflow.mjs +632 -0
  86. package/lib/ro-entry-reader.mjs +56 -0
  87. package/lib/ro-resubmit-workflow.mjs +134 -0
  88. package/lib/terminal-auth-prompts.mjs +120 -0
  89. package/lib/ui.mjs +133 -0
  90. package/lib/update-service.mjs +350 -0
  91. package/lib/util.mjs +66 -0
  92. package/lib/windows-credential-manager.mjs +260 -0
  93. package/lib/worklog-task-resolver.mjs +113 -0
  94. package/package.json +73 -4
  95. package/setup.sh +22 -0
  96. package/skills/logwork/SKILL.md +43 -0
@@ -0,0 +1,130 @@
1
+ import {
2
+ createKeychainEmailStorage,
3
+ createKeychainTokenStorage,
4
+ KEYCHAIN_ACCOUNT,
5
+ KEYCHAIN_EMAIL_ACCOUNT,
6
+ KEYCHAIN_SERVICE,
7
+ LEGACY_KEYCHAIN_SERVICE
8
+ } from './keychain.mjs';
9
+ import {
10
+ createWindowsCredentialEmailStorage,
11
+ createWindowsCredentialTokenStorage
12
+ } from './windows-credential-manager.mjs';
13
+
14
+ export const SUPPORTED_CREDENTIAL_PLATFORMS = Object.freeze(['darwin', 'win32']);
15
+
16
+ export function credentialStoreLabel({ platform = process.platform } = {}) {
17
+ if (platform === 'darwin') {
18
+ return 'macOS Keychain';
19
+ }
20
+ if (platform === 'win32') {
21
+ return 'Windows Credential Manager';
22
+ }
23
+ return 'OS credential store';
24
+ }
25
+
26
+ export function assertSupportedCredentialPlatform(platform = process.platform) {
27
+ if (!SUPPORTED_CREDENTIAL_PLATFORMS.includes(platform)) {
28
+ throw unsupportedCredentialStoreError(platform);
29
+ }
30
+ }
31
+
32
+ export function createCredentialTokenStorage({
33
+ platform = process.platform,
34
+ service = KEYCHAIN_SERVICE,
35
+ account = KEYCHAIN_ACCOUNT,
36
+ run
37
+ } = {}) {
38
+ const create = (name) => {
39
+ if (platform === 'darwin') {
40
+ return createKeychainTokenStorage({ service: name, account, ...(run ? { run } : {}) });
41
+ }
42
+ if (platform === 'win32') {
43
+ return createWindowsCredentialTokenStorage({ service: name, account, ...(run ? { run } : {}) });
44
+ }
45
+ return createUnsupportedCredentialStorage(platform);
46
+ };
47
+ return withLegacyService({ platform, service, create });
48
+ }
49
+
50
+ export function createCredentialEmailStorage({
51
+ platform = process.platform,
52
+ service = KEYCHAIN_SERVICE,
53
+ account = KEYCHAIN_EMAIL_ACCOUNT,
54
+ run
55
+ } = {}) {
56
+ const create = (name) => {
57
+ if (platform === 'darwin') {
58
+ return createKeychainEmailStorage({ service: name, account, ...(run ? { run } : {}) });
59
+ }
60
+ if (platform === 'win32') {
61
+ return createWindowsCredentialEmailStorage({ service: name, account, ...(run ? { run } : {}) });
62
+ }
63
+ return createUnsupportedCredentialStorage(platform);
64
+ };
65
+ return withLegacyService({ platform, service, create });
66
+ }
67
+
68
+ // Sessions saved before the logwork-helper -> logwork-cli rename live under the
69
+ // legacy service name. On a miss, read the legacy entry once and copy it to the
70
+ // new service so nobody has to log in again. Delete clears both entries.
71
+ export function withLegacyService({ platform, service, create, legacyService = LEGACY_KEYCHAIN_SERVICE }) {
72
+ const primary = create(service);
73
+ if (service !== KEYCHAIN_SERVICE || !SUPPORTED_CREDENTIAL_PLATFORMS.includes(platform)) {
74
+ return primary;
75
+ }
76
+ const legacy = create(legacyService);
77
+ return {
78
+ async get() {
79
+ const value = await primary.get();
80
+ if (value) {
81
+ return value;
82
+ }
83
+ const legacyValue = await legacy.get().catch(() => null);
84
+ if (legacyValue) {
85
+ await primary.set(legacyValue).catch(() => {});
86
+ }
87
+ return legacyValue || null;
88
+ },
89
+ set(value) {
90
+ return primary.set(value);
91
+ },
92
+ async delete() {
93
+ const result = await primary.delete();
94
+ await legacy.delete().catch(() => {});
95
+ return result;
96
+ }
97
+ };
98
+ }
99
+
100
+ export function createCredentialJiraStorage({
101
+ platform = process.platform,
102
+ service = KEYCHAIN_SERVICE,
103
+ account = 'jira',
104
+ run
105
+ } = {}) {
106
+ return createCredentialTokenStorage({
107
+ platform,
108
+ service,
109
+ account,
110
+ ...(run ? { run } : {})
111
+ });
112
+ }
113
+
114
+ function createUnsupportedCredentialStorage(platform) {
115
+ return {
116
+ async get() {
117
+ throw unsupportedCredentialStoreError(platform);
118
+ },
119
+ async set() {
120
+ throw unsupportedCredentialStoreError(platform);
121
+ },
122
+ async delete() {
123
+ throw unsupportedCredentialStoreError(platform);
124
+ }
125
+ };
126
+ }
127
+
128
+ function unsupportedCredentialStoreError(platform) {
129
+ return new Error(`Unsupported operating system for logwork credential storage: ${platform}. Supported platforms are macOS and Windows.`);
130
+ }
@@ -0,0 +1,307 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { constants as fsConstants } from 'node:fs';
3
+ import { access, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
4
+ import { EOL, homedir, platform, release, type } from 'node:os';
5
+ import { dirname, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
8
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
9
+ import { CONFIG } from './config.mjs';
10
+ import { getStoredAuthStatus } from './auth.mjs';
11
+ import { redactText } from './auth-redaction.mjs';
12
+ import { authDebugLogPath, sanitizeDiagnosticValue } from './auth-diagnostics.mjs';
13
+ import { configPath, loadLocalConfig } from './logwork-config.mjs';
14
+ import { helperHome, manualDraftsPath, userConfigPath } from './paths.mjs';
15
+
16
+ const DIAGNOSTICS_DIR_NAME = 'diagnostics';
17
+ const MAX_AUTH_DEBUG_CHARS = 20_000;
18
+ const MCP_SMOKE_TIMEOUT_MS = 5_000;
19
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
20
+
21
+ export function diagnosticsDir() {
22
+ return resolve(helperHome(), DIAGNOSTICS_DIR_NAME);
23
+ }
24
+
25
+ export function diagnosticsReportPath(now = new Date()) {
26
+ const stamp = now.toISOString().replace(/[:.]/g, '-');
27
+ return resolve(diagnosticsDir(), `logwork-diagnostics-${stamp}.txt`);
28
+ }
29
+
30
+ export async function generateDiagnosticsReport({
31
+ outputPath = diagnosticsReportPath(),
32
+ now = () => new Date(),
33
+ cwd = process.cwd(),
34
+ includeMappingDetails = process.env.LOGWORK_DIAGNOSTICS_INCLUDE_MAPPINGS === '1',
35
+ commandFinder = findCommandOnPath,
36
+ authStatusFn = getStoredAuthStatus,
37
+ mcpSmokeFn = runMcpSmoke,
38
+ readFileFn = readFile,
39
+ statFn = stat,
40
+ writeFileFn = writeFile,
41
+ mkdirFn = mkdir
42
+ } = {}) {
43
+ const sections = [];
44
+ const packageInfo = await readPackageInfo({ readFileFn });
45
+ const commandChecks = await collectCommandChecks(commandFinder);
46
+ const authStatus = await safeCollect('auth status', async () => sanitizeAuthStatus(await authStatusFn()));
47
+ const fileChecks = await collectFileChecks({ statFn, cwd });
48
+ const configSnapshot = await safeCollect('config snapshot', () => collectConfigSnapshot({ cwd, includeMappingDetails }));
49
+ const mcpSmoke = await safeCollect('mcp smoke', () => mcpSmokeFn({ cwd }));
50
+ const authDebug = await readSanitizedAuthDebug({ readFileFn });
51
+
52
+ sections.push(formatSection('logwork-cli Diagnostics', [
53
+ `generatedAt: ${now().toISOString()}`,
54
+ `packageVersion: ${packageInfo.version || 'unknown'}`,
55
+ `cwd: ${cwd}`,
56
+ `helperHome: ${helperHome()}`
57
+ ]));
58
+
59
+ sections.push(formatSection('Runtime', [
60
+ `platform: ${platform()}`,
61
+ `os: ${type()} ${release()}`,
62
+ `node: ${process.version}`,
63
+ `nodePath: ${process.execPath}`,
64
+ `home: ${homedir()}`
65
+ ]));
66
+
67
+ sections.push(formatSection('Commands', commandChecks.map((entry) => `${entry.command}: ${entry.available ? 'found' : 'missing'}${entry.path ? ` (${entry.path})` : ''}`)));
68
+ sections.push(formatSection('Files', fileChecks.map((entry) => formatFileCheck(entry))));
69
+ sections.push(formatSection('Config Snapshot', formatObjectLines(configSnapshot)));
70
+ sections.push(formatSection('Auth Status', formatObjectLines(authStatus)));
71
+ sections.push(formatSection('MCP Smoke', formatObjectLines(mcpSmoke)));
72
+ sections.push(formatSection('Recent Auth Debug', authDebug ? [authDebug] : ['No auth-debug.log found.']));
73
+
74
+ const report = sanitizeReport(`${sections.join(`${EOL}${EOL}`)}${EOL}`);
75
+ await mkdirFn(dirname(outputPath), { recursive: true });
76
+ await writeFileFn(outputPath, report, 'utf8');
77
+
78
+ return {
79
+ path: outputPath,
80
+ report,
81
+ summary: `Diagnostics report written to ${outputPath}`
82
+ };
83
+ }
84
+
85
+ async function readPackageInfo({ readFileFn }) {
86
+ try {
87
+ const packagePath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
88
+ return JSON.parse(await readFileFn(packagePath, 'utf8'));
89
+ } catch {
90
+ return {};
91
+ }
92
+ }
93
+
94
+ async function collectCommandChecks(commandFinder) {
95
+ const commands = ['node', 'npm', 'logwork'];
96
+ const output = [];
97
+ for (const command of commands) {
98
+ output.push({ command, ...await commandFinder(command) });
99
+ }
100
+ return output;
101
+ }
102
+
103
+ async function collectFileChecks({ statFn, cwd }) {
104
+ const files = [
105
+ { label: 'userConfig', path: userConfigPath() },
106
+ { label: 'projectConfig', path: configPath(cwd, 'project') },
107
+ { label: 'manualDrafts', path: manualDraftsPath() },
108
+ { label: 'authDebugLog', path: authDebugLogPath() }
109
+ ];
110
+
111
+ const output = [];
112
+ for (const file of files) {
113
+ try {
114
+ const info = await statFn(file.path);
115
+ output.push({
116
+ ...file,
117
+ exists: true,
118
+ size: info.size,
119
+ modifiedAt: info.mtime?.toISOString?.() || null
120
+ });
121
+ } catch (error) {
122
+ output.push({
123
+ ...file,
124
+ exists: false,
125
+ error: error.code === 'ENOENT' ? null : error.message
126
+ });
127
+ }
128
+ }
129
+ return output;
130
+ }
131
+
132
+ async function collectConfigSnapshot({ cwd, includeMappingDetails = false }) {
133
+ const config = await loadLocalConfig(cwd);
134
+ const mappings = config.projectMappings || [];
135
+ return {
136
+ profile: CONFIG.profile,
137
+ apiHost: hostFromUrl(CONFIG.apiBase),
138
+ loginHost: hostFromUrl(CONFIG.loginUrl),
139
+ keycloakHost: hostFromUrl(CONFIG.keycloakAuthUrl),
140
+ allowedSafariHosts: CONFIG.allowedSafariHosts,
141
+ httpTimeoutMs: CONFIG.httpTimeoutMs,
142
+ httpReadRetries: CONFIG.httpReadRetries,
143
+ userConfigPath: configPath(cwd, 'user'),
144
+ projectConfigPath: configPath(cwd, 'project'),
145
+ mappingCount: mappings.length,
146
+ mappingTicketCount: mappings.reduce((sum, mapping) => sum + (mapping.tickets || []).length, 0),
147
+ mappingKeywordCount: mappings.reduce((sum, mapping) => sum + (mapping.keywords || []).length, 0),
148
+ mappingDetails: includeMappingDetails ? mappings.map((mapping) => ({
149
+ projectMemberId: mapping.projectMemberId ?? null,
150
+ projectName: mapping.projectName || '',
151
+ tickets: mapping.tickets || [],
152
+ keywordCount: (mapping.keywords || []).length
153
+ })) : '<redacted; set LOGWORK_DIAGNOSTICS_INCLUDE_MAPPINGS=1 to include mapping details>'
154
+ };
155
+ }
156
+
157
+ function hostFromUrl(value) {
158
+ try {
159
+ return new URL(value).host;
160
+ } catch {
161
+ return 'invalid';
162
+ }
163
+ }
164
+
165
+ async function readSanitizedAuthDebug({ readFileFn }) {
166
+ try {
167
+ const content = await readFileFn(authDebugLogPath(), 'utf8');
168
+ const tail = content.slice(-MAX_AUTH_DEBUG_CHARS);
169
+ return sanitizeDiagnosticText(tail);
170
+ } catch (error) {
171
+ if (error.code === 'ENOENT') {
172
+ return '';
173
+ }
174
+ return `Unable to read auth debug log: ${sanitizeDiagnosticValue(error.message)}`;
175
+ }
176
+ }
177
+
178
+ function sanitizeAuthStatus(status) {
179
+ if (!status || typeof status !== 'object') {
180
+ return { available: false };
181
+ }
182
+ return {
183
+ authenticated: Boolean(status.authenticated),
184
+ expired: Boolean(status.expired),
185
+ refreshAvailable: Boolean(status.refreshAvailable),
186
+ userId: status.userId || null,
187
+ email: status.email || null,
188
+ expiresAt: status.expiresAt || null,
189
+ summary: status.summary || ''
190
+ };
191
+ }
192
+
193
+ async function safeCollect(label, fn) {
194
+ try {
195
+ return await fn();
196
+ } catch (error) {
197
+ return {
198
+ ok: false,
199
+ label,
200
+ error: sanitizeDiagnosticValue(error.message || String(error))
201
+ };
202
+ }
203
+ }
204
+
205
+ export async function runMcpSmoke({ cwd = process.cwd(), timeoutMs = MCP_SMOKE_TIMEOUT_MS } = {}) {
206
+ const client = new Client({
207
+ name: 'logwork-diagnostics',
208
+ version: '0.1.0'
209
+ });
210
+ const transport = new StdioClientTransport({
211
+ command: process.execPath,
212
+ args: [resolve(PACKAGE_ROOT, 'bin', 'logwork.mjs'), 'mcp'],
213
+ cwd: PACKAGE_ROOT,
214
+ env: {
215
+ ...process.env,
216
+ LOGWORK_HOME: helperHome()
217
+ },
218
+ stderr: 'pipe'
219
+ });
220
+
221
+ let timeout;
222
+ try {
223
+ await Promise.race([
224
+ client.connect(transport),
225
+ new Promise((_, reject) => {
226
+ timeout = setTimeout(() => reject(new Error('MCP connect timed out.')), timeoutMs);
227
+ })
228
+ ]);
229
+ const result = await Promise.race([
230
+ client.listTools(),
231
+ new Promise((_, reject) => {
232
+ clearTimeout(timeout);
233
+ timeout = setTimeout(() => reject(new Error('MCP tools/list timed out.')), timeoutMs);
234
+ })
235
+ ]);
236
+ return {
237
+ ok: true,
238
+ toolCount: result.tools.length,
239
+ tools: result.tools.map((tool) => tool.name).sort()
240
+ };
241
+ } finally {
242
+ clearTimeout(timeout);
243
+ await client.close().catch(() => {});
244
+ }
245
+ }
246
+
247
+ async function findCommandOnPath(commandName) {
248
+ const pathEntries = (process.env.PATH || '').split(process.platform === 'win32' ? ';' : ':').filter(Boolean);
249
+ const names = process.platform === 'win32' ? [commandName, `${commandName}.cmd`, `${commandName}.exe`] : [commandName];
250
+ for (const pathEntry of pathEntries) {
251
+ for (const name of names) {
252
+ const candidate = resolve(pathEntry, name);
253
+ try {
254
+ await stat(candidate);
255
+ await access(candidate, fsConstants.X_OK);
256
+ return { available: true, path: candidate };
257
+ } catch {
258
+ // Keep searching.
259
+ }
260
+ }
261
+ }
262
+ return { available: false, path: null };
263
+ }
264
+
265
+ function formatSection(title, lines = []) {
266
+ return [`# ${title}`, ...lines.map((line) => String(line))].join(EOL);
267
+ }
268
+
269
+ function formatObjectLines(value) {
270
+ if (!value || typeof value !== 'object') {
271
+ return [String(value ?? '')];
272
+ }
273
+ return Object.entries(value).map(([key, entryValue]) => `${key}: ${formatValue(entryValue)}`);
274
+ }
275
+
276
+ function formatValue(value) {
277
+ if (Array.isArray(value)) {
278
+ return value.map((item) => {
279
+ if (item && typeof item === 'object') {
280
+ return JSON.stringify(sanitizeDiagnosticValue(item));
281
+ }
282
+ return String(sanitizeDiagnosticValue(item));
283
+ }).join(', ');
284
+ }
285
+ if (value && typeof value === 'object') {
286
+ return JSON.stringify(sanitizeDiagnosticValue(value));
287
+ }
288
+ return String(sanitizeDiagnosticValue(value));
289
+ }
290
+
291
+ function formatFileCheck(entry) {
292
+ if (!entry.exists) {
293
+ return `${entry.label}: missing (${entry.path})${entry.error ? ` error=${entry.error}` : ''}`;
294
+ }
295
+ return `${entry.label}: exists (${entry.path}) size=${entry.size} modifiedAt=${entry.modifiedAt}`;
296
+ }
297
+
298
+ function sanitizeReport(report) {
299
+ return redactText(String(report || ''));
300
+ }
301
+
302
+ function sanitizeDiagnosticText(value) {
303
+ let text = redactText(String(value || ''));
304
+ text = text.replace(/("(?:password|otp|cookie|authorization|token|accessToken|refreshToken|access_token|refresh_token|code|session_code|state|nonce|tab_id|credentialId)"\s*:\s*)"[^"]*"/gi, '$1"<redacted>"');
305
+ text = text.replace(/<\/?(html|body|form|input|script|style)\b[^>]*>/gi, '<html-redacted>');
306
+ return text;
307
+ }
@@ -0,0 +1,14 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { pathToFileURL } from 'node:url';
3
+
4
+ export function isMainModule(importMetaUrl, argv = process.argv) {
5
+ if (!argv[1]) {
6
+ return false;
7
+ }
8
+
9
+ try {
10
+ return importMetaUrl === pathToFileURL(realpathSync(argv[1])).href;
11
+ } catch {
12
+ return importMetaUrl === pathToFileURL(argv[1]).href;
13
+ }
14
+ }
@@ -0,0 +1,44 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { setTimeout as delay } from 'node:timers/promises';
4
+
5
+ const DEFAULT_LOCK_TIMEOUT_MS = 10_000;
6
+ const DEFAULT_RETRY_DELAY_MS = 25;
7
+
8
+ export async function withFileLock(filePath, operation, {
9
+ timeoutMs = DEFAULT_LOCK_TIMEOUT_MS,
10
+ retryDelayMs = DEFAULT_RETRY_DELAY_MS,
11
+ mkdirFn = fs.mkdir,
12
+ rmFn = fs.rm,
13
+ now = Date.now,
14
+ delayFn = delay
15
+ } = {}) {
16
+ if (typeof operation !== 'function') {
17
+ throw new Error('withFileLock requires an operation function.');
18
+ }
19
+
20
+ await mkdirFn(dirname(filePath), { recursive: true });
21
+ const lockPath = `${filePath}.lock`;
22
+ const startedAt = now();
23
+
24
+ while (true) {
25
+ try {
26
+ await mkdirFn(lockPath);
27
+ break;
28
+ } catch (error) {
29
+ if (error.code !== 'EEXIST') {
30
+ throw error;
31
+ }
32
+ if (now() - startedAt >= timeoutMs) {
33
+ throw new Error(`Timed out waiting for file lock: ${filePath}`);
34
+ }
35
+ await delayFn(retryDelayMs);
36
+ }
37
+ }
38
+
39
+ try {
40
+ return await operation();
41
+ } finally {
42
+ await rmFn(lockPath, { recursive: true, force: true }).catch(() => {});
43
+ }
44
+ }
package/lib/http.mjs ADDED
@@ -0,0 +1,125 @@
1
+ import { redactText } from './auth-redaction.mjs';
2
+
3
+ const RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
4
+
5
+ export async function fetchWithPolicy(url, {
6
+ fetchImpl = fetch,
7
+ timeoutMs = 30_000,
8
+ retries = 0,
9
+ retryDelayMs = 250,
10
+ idempotent = false,
11
+ ...options
12
+ } = {}) {
13
+ const attempts = idempotent ? Math.max(0, Number(retries) || 0) + 1 : 1;
14
+ let lastError = null;
15
+
16
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
17
+ const controller = new AbortController();
18
+ const timeout = setTimeout(() => controller.abort(), Math.max(1, Number(timeoutMs) || 30_000));
19
+ const signal = mergeAbortSignals(options.signal, controller.signal);
20
+
21
+ try {
22
+ const response = await fetchImpl(url, {
23
+ ...options,
24
+ signal
25
+ });
26
+ clearTimeout(timeout);
27
+
28
+ if (
29
+ attempt < attempts &&
30
+ RETRYABLE_STATUSES.has(response.status)
31
+ ) {
32
+ await discardResponseBody(response);
33
+ await delay(retryDelayMs);
34
+ continue;
35
+ }
36
+
37
+ return response;
38
+ } catch (error) {
39
+ clearTimeout(timeout);
40
+ lastError = error;
41
+
42
+ if (isAbortError(error)) {
43
+ throw httpPolicyError(`HTTP request timed out for ${safeUrlLabel(url)} after ${Math.max(1, Number(timeoutMs) || 30_000)}ms.`, {
44
+ code: 'HTTP_TIMEOUT',
45
+ cause: error
46
+ });
47
+ }
48
+
49
+ if (attempt < attempts) {
50
+ await delay(retryDelayMs);
51
+ continue;
52
+ }
53
+
54
+ throw httpPolicyError(`HTTP request failed for ${safeUrlLabel(url)}: ${error.message || String(error)}`, {
55
+ code: 'HTTP_NETWORK_ERROR',
56
+ cause: error
57
+ });
58
+ }
59
+ }
60
+
61
+ throw httpPolicyError(`HTTP request failed for ${safeUrlLabel(url)}: ${lastError?.message || 'unknown network error'}`, {
62
+ code: 'HTTP_NETWORK_ERROR',
63
+ cause: lastError
64
+ });
65
+ }
66
+
67
+ export function redactedExcerpt(value, maxLength = 500) {
68
+ let text = redactText(String(value || ''));
69
+ text = text.replace(/("(?:password|otp|cookie|authorization|token|accessToken|refreshToken|access_token|refresh_token|code|session_code|state|nonce|tab_id|credentialId)"\s*:\s*)"[^"]*"/gi, '$1"<redacted>"');
70
+ return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
71
+ }
72
+
73
+ function mergeAbortSignals(signal, timeoutSignal) {
74
+ if (!signal) {
75
+ return timeoutSignal;
76
+ }
77
+
78
+ if (typeof AbortSignal.any === 'function') {
79
+ return AbortSignal.any([signal, timeoutSignal]);
80
+ }
81
+
82
+ return timeoutSignal;
83
+ }
84
+
85
+ function isAbortError(error) {
86
+ return error?.name === 'AbortError' || error?.code === 'ABORT_ERR';
87
+ }
88
+
89
+ function httpPolicyError(message, { code, cause } = {}) {
90
+ const error = new Error(redactText(message));
91
+ error.code = code;
92
+ if (cause) {
93
+ error.cause = cause;
94
+ }
95
+ return error;
96
+ }
97
+
98
+ async function discardResponseBody(response) {
99
+ try {
100
+ if (response?.body && typeof response.body.cancel === 'function') {
101
+ await response.body.cancel();
102
+ return;
103
+ }
104
+
105
+ if (typeof response?.arrayBuffer === 'function') {
106
+ await response.arrayBuffer();
107
+ }
108
+ } catch {
109
+ // Best-effort cleanup before retry; the retry itself is more important.
110
+ }
111
+ }
112
+
113
+ function safeUrlLabel(rawUrl) {
114
+ try {
115
+ const url = new URL(rawUrl);
116
+ return `${url.origin}${url.pathname}`;
117
+ } catch {
118
+ return 'unknown-url';
119
+ }
120
+ }
121
+
122
+ function delay(ms) {
123
+ const waitMs = Math.max(0, Number(ms) || 0);
124
+ return waitMs ? new Promise((resolve) => setTimeout(resolve, waitMs)) : Promise.resolve();
125
+ }