@lumpcode/cli-utils 0.1.0 → 0.2.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.
package/dist/index.cjs CHANGED
@@ -5,6 +5,7 @@ var core = require('@lumpcode/core');
5
5
  var path = require('node:path');
6
6
  var os = require('node:os');
7
7
  var crypto = require('node:crypto');
8
+ var fsSync = require('node:fs');
8
9
  var fs = require('node:fs/promises');
9
10
  var jsYaml = require('js-yaml');
10
11
 
@@ -28,6 +29,7 @@ function _interopNamespaceDefault(e) {
28
29
  var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path);
29
30
  var os__namespace = /*#__PURE__*/_interopNamespaceDefault(os);
30
31
  var crypto__namespace = /*#__PURE__*/_interopNamespaceDefault(crypto);
32
+ var fsSync__namespace = /*#__PURE__*/_interopNamespaceDefault(fsSync);
31
33
  var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs);
32
34
 
33
35
  const globalConfigFolderPath = path.join(os.homedir(), '.lumpcode');
@@ -99,6 +101,8 @@ function formatJsonFileContent(input) {
99
101
  return trailingNewline ? `${json}\n` : json;
100
102
  }
101
103
 
104
+ const WORKSPACE_LOCK_WAIT_TIMEOUT_MS = 15 * 60 * 1000;
105
+ const WORKSPACE_LOCK_WAIT_LOG_INTERVAL_MS = 30000;
102
106
  const WAIT_POLL_MS = 500;
103
107
  function workspaceLocksDirPath(input) {
104
108
  return path__namespace.join(input.globalConfigFolderPath, input.spec.locksSubdirName);
@@ -111,26 +115,53 @@ function workspaceLockFilePath(input) {
111
115
  spec: input.spec,
112
116
  }), `${hash}.lock.json`);
113
117
  }
114
- function formatBusyMessage(input) {
115
- const { spec, workspacePath, holder } = input;
118
+ function formatHolderClause(holder, style) {
116
119
  if (holder?.lumpName && holder.pid) {
117
- return (`${spec.workspaceLabel} "${workspacePath}" is in use by another lumpcode run ` +
118
- `(pid ${holder.pid}, lump "${holder.lumpName}"). Wait for it to finish or stop the daemon before running again.`);
120
+ return style === 'busy'
121
+ ? ` (pid ${holder.pid}, lump "${holder.lumpName}")`
122
+ : ` (held by lump "${holder.lumpName}" pid ${holder.pid})`;
119
123
  }
120
124
  if (holder?.pid) {
121
- return (`${spec.workspaceLabel} "${workspacePath}" is in use by another lumpcode run ` +
122
- `(pid ${holder.pid}). Wait for it to finish or stop the daemon before running again.`);
125
+ return style === 'busy' ? ` (pid ${holder.pid})` : ` (held by pid ${holder.pid})`;
126
+ }
127
+ return '';
128
+ }
129
+ function formatBusyMessage(input) {
130
+ const { spec, workspacePath, holder } = input;
131
+ const clause = formatHolderClause(holder, 'busy');
132
+ if (clause) {
133
+ return (`${spec.workspaceLabel} "${workspacePath}" is in use by another lumpcode run` +
134
+ `${clause}. Wait for it to finish or stop the daemon before running again.`);
123
135
  }
124
136
  return (`${spec.workspaceLabel} "${workspacePath}" is in use by another lumpcode run. ` +
125
137
  `Wait for it to finish or stop the daemon before running again.`);
126
138
  }
127
139
  function formatWorkspaceFileWaitMessage(input) {
128
140
  const { spec, workspacePath, holder } = input;
129
- if (holder?.lumpName && holder.pid) {
130
- return (`${spec.waitLogNoun} busy at "${workspacePath}" ` +
131
- `(held by lump "${holder.lumpName}" pid ${holder.pid}); waiting…`);
132
- }
133
- return `${spec.waitLogNoun} busy at "${workspacePath}"; waiting…`;
141
+ return (`${spec.waitLogNoun} busy at "${workspacePath}"` +
142
+ `${formatHolderClause(holder, 'held')}; waiting…`);
143
+ }
144
+ function formatWorkspaceFileStillWaitingMessage(input) {
145
+ const { spec, workspacePath, holder, elapsedMs } = input;
146
+ const elapsedSec = Math.max(1, Math.round(elapsedMs / 1000));
147
+ return (`${spec.waitLogNoun} busy at "${workspacePath}"` +
148
+ `${formatHolderClause(holder, 'held')}; still waiting (${elapsedSec}s)…`);
149
+ }
150
+ function formatWaitTimeoutMessage(input) {
151
+ const { spec, workspacePath, holder, waitTimeoutMs } = input;
152
+ return (`Waited ${waitTimeoutMs}ms for ${spec.waitLogNoun} "${workspacePath}"` +
153
+ `${formatHolderClause(holder, 'held')}; giving up.`);
154
+ }
155
+ function toBusyError(input) {
156
+ const { spec, workspacePath, holder, message, reason } = input;
157
+ return {
158
+ code: spec.busyCode,
159
+ message,
160
+ [spec.workspacePathField]: workspacePath,
161
+ ...(holder?.pid !== undefined ? { holderPid: holder.pid } : {}),
162
+ ...(holder?.lumpName !== undefined ? { holderLumpName: holder.lumpName } : {}),
163
+ ...(reason !== undefined ? { reason } : {}),
164
+ };
134
165
  }
135
166
  async function readLockHolder(lockFilePath) {
136
167
  const result = await readJsonFile({ filePath: lockFilePath, ifMissing: 'undefined' });
@@ -173,6 +204,8 @@ async function tryAcquireWorkspaceFileLockOnce(input) {
173
204
  }
174
205
  async function acquireWorkspaceFileLock(input) {
175
206
  const { spec, globalConfigFolderPath, workspacePath, lumpName, mode, projectName, logger } = input;
207
+ const waitTimeoutMs = input.waitTimeoutMs ?? WORKSPACE_LOCK_WAIT_TIMEOUT_MS;
208
+ const waitLogIntervalMs = input.waitLogIntervalMs ?? WORKSPACE_LOCK_WAIT_LOG_INTERVAL_MS;
176
209
  const normalizedWorkspacePath = path__namespace.resolve(workspacePath);
177
210
  const locksDir = workspaceLocksDirPath({ globalConfigFolderPath, spec });
178
211
  await fs__namespace.mkdir(locksDir, { recursive: true });
@@ -189,10 +222,12 @@ async function acquireWorkspaceFileLock(input) {
189
222
  ...(projectName !== undefined ? { projectName } : {}),
190
223
  };
191
224
  let loggedWait = false;
225
+ let lastWaitLogAt = 0;
226
+ const waitStartedAt = Date.now();
192
227
  for (;;) {
193
228
  const attempt = await tryAcquireWorkspaceFileLockOnce({ lockFilePath, payload, spec, logger });
194
229
  if (attempt.status === 'acquired') {
195
- const release = async () => {
230
+ const releaseAsync = async () => {
196
231
  try {
197
232
  const holder = await readLockHolder(lockFilePath);
198
233
  if (holder?.pid === process.pid) {
@@ -203,6 +238,20 @@ async function acquireWorkspaceFileLock(input) {
203
238
  // lock already gone
204
239
  }
205
240
  };
241
+ const release = Object.assign(releaseAsync, {
242
+ sync: () => {
243
+ try {
244
+ const raw = fsSync__namespace.readFileSync(lockFilePath, 'utf8');
245
+ const holder = JSON.parse(raw);
246
+ if (holder?.pid === process.pid) {
247
+ fsSync__namespace.unlinkSync(lockFilePath);
248
+ }
249
+ }
250
+ catch {
251
+ // lock already gone or unreadable
252
+ }
253
+ },
254
+ });
206
255
  return core.success(release);
207
256
  }
208
257
  if (attempt.status === 'stale_removed') {
@@ -210,29 +259,51 @@ async function acquireWorkspaceFileLock(input) {
210
259
  continue;
211
260
  }
212
261
  if (mode === 'fail') {
213
- return core.failure({
214
- code: spec.busyCode,
262
+ return core.failure(toBusyError({
263
+ spec,
264
+ workspacePath: normalizedWorkspacePath,
265
+ holder: attempt.holder,
215
266
  message: formatBusyMessage({
216
267
  spec,
217
268
  workspacePath: normalizedWorkspacePath,
218
269
  holder: attempt.holder,
219
270
  }),
220
- [spec.workspacePathField]: normalizedWorkspacePath,
221
- ...(attempt.holder?.pid !== undefined ? { holderPid: attempt.holder.pid } : {}),
222
- ...(attempt.holder?.lumpName !== undefined
223
- ? { holderLumpName: attempt.holder.lumpName }
224
- : {}),
225
- });
271
+ }));
226
272
  }
227
- if (!loggedWait) {
228
- logger?.info(formatWorkspaceFileWaitMessage({
273
+ const now = Date.now();
274
+ const elapsedMs = now - waitStartedAt;
275
+ if (elapsedMs >= waitTimeoutMs) {
276
+ return core.failure(toBusyError({
229
277
  spec,
230
278
  workspacePath: normalizedWorkspacePath,
231
279
  holder: attempt.holder,
280
+ reason: 'waitTimedOut',
281
+ message: formatWaitTimeoutMessage({
282
+ spec,
283
+ workspacePath: normalizedWorkspacePath,
284
+ holder: attempt.holder,
285
+ waitTimeoutMs,
286
+ }),
232
287
  }));
288
+ }
289
+ if (!loggedWait || now - lastWaitLogAt >= waitLogIntervalMs) {
290
+ logger?.info(loggedWait
291
+ ? formatWorkspaceFileStillWaitingMessage({
292
+ spec,
293
+ workspacePath: normalizedWorkspacePath,
294
+ holder: attempt.holder,
295
+ elapsedMs,
296
+ })
297
+ : formatWorkspaceFileWaitMessage({
298
+ spec,
299
+ workspacePath: normalizedWorkspacePath,
300
+ holder: attempt.holder,
301
+ }));
233
302
  loggedWait = true;
303
+ lastWaitLogAt = now;
234
304
  }
235
- await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_MS));
305
+ const remainingMs = waitTimeoutMs - elapsedMs;
306
+ await new Promise((resolve) => setTimeout(resolve, Math.min(WAIT_POLL_MS, Math.max(0, remainingMs))));
236
307
  }
237
308
  }
238
309
 
@@ -276,6 +347,8 @@ async function acquireGitCommonDirLock(input) {
276
347
  mode: input.lockMode,
277
348
  projectName: input.projectName,
278
349
  logger: input.logger,
350
+ waitTimeoutMs: input.waitTimeoutMs,
351
+ waitLogIntervalMs: input.waitLogIntervalMs,
279
352
  });
280
353
  }
281
354
  /** Acquire, run `fn`, always release. */
package/dist/index.d.ts CHANGED
@@ -64,7 +64,7 @@ type LumpJsConfigStep<V extends LumpVariables = LumpVariables, SV extends StepVa
64
64
  type LumpJsConfigSoloStep<V extends LumpVariables = LumpVariables, SV extends StepVariables = StepVariables> = LumpJsConfigStep<V, SV> | LumpJsConfigStep<V, SV>['promptTemplate'] | LumpJsConfigStep<V, SV>['promptFn'];
65
65
  type LumpJsConfig<V extends LumpVariables = LumpVariables, SV extends StepVariables = StepVariables> = MergeObjs<Omit<{
66
66
  [K in keyof RunLumpInput<V, SV>]?: NonNullable<RunLumpInput<V, SV>[K]> extends Function ? (RunLumpInput<V, SV>[K] | FilePath) : RunLumpInput<V, SV>[K];
67
- }, 'gitCommitMessageFn' | 'projectRoot' | 'branchFn' | 'baseBranch' | 'setupWorkspaceFn' | 'teardownWorkspaceFn' | 'gitAddCommandFn' | 'gitCommitCommandFn' | 'gitPushCommandFn' | 'getContextListFn' | 'refreshRemoteTrackingRefsFn'>, {
67
+ }, 'gitCommitMessageFn' | 'projectRoot' | 'branchFn' | 'baseBranch' | 'setupWorkspaceFn' | 'teardownWorkspaceFn' | 'gitAddCommitFn' | 'gitPushFn' | 'getContextListFn' | 'refreshRemoteTrackingRefsFn'>, {
68
68
  /**
69
69
  * Execution integration branch. Exact string, `BaseBranchFn`, or FilePath to a module.
70
70
  * Omit → concrete effective discovery branch. Pattern strings are rejected at resolve.
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import path__default from 'node:path';
5
5
  import * as os from 'node:os';
6
6
  import os__default from 'node:os';
7
7
  import * as crypto from 'node:crypto';
8
+ import * as fsSync from 'node:fs';
8
9
  import * as fs from 'node:fs/promises';
9
10
  import { load } from 'js-yaml';
10
11
 
@@ -77,6 +78,8 @@ function formatJsonFileContent(input) {
77
78
  return trailingNewline ? `${json}\n` : json;
78
79
  }
79
80
 
81
+ const WORKSPACE_LOCK_WAIT_TIMEOUT_MS = 15 * 60 * 1000;
82
+ const WORKSPACE_LOCK_WAIT_LOG_INTERVAL_MS = 30000;
80
83
  const WAIT_POLL_MS = 500;
81
84
  function workspaceLocksDirPath(input) {
82
85
  return path.join(input.globalConfigFolderPath, input.spec.locksSubdirName);
@@ -89,26 +92,53 @@ function workspaceLockFilePath(input) {
89
92
  spec: input.spec,
90
93
  }), `${hash}.lock.json`);
91
94
  }
92
- function formatBusyMessage(input) {
93
- const { spec, workspacePath, holder } = input;
95
+ function formatHolderClause(holder, style) {
94
96
  if (holder?.lumpName && holder.pid) {
95
- return (`${spec.workspaceLabel} "${workspacePath}" is in use by another lumpcode run ` +
96
- `(pid ${holder.pid}, lump "${holder.lumpName}"). Wait for it to finish or stop the daemon before running again.`);
97
+ return style === 'busy'
98
+ ? ` (pid ${holder.pid}, lump "${holder.lumpName}")`
99
+ : ` (held by lump "${holder.lumpName}" pid ${holder.pid})`;
97
100
  }
98
101
  if (holder?.pid) {
99
- return (`${spec.workspaceLabel} "${workspacePath}" is in use by another lumpcode run ` +
100
- `(pid ${holder.pid}). Wait for it to finish or stop the daemon before running again.`);
102
+ return style === 'busy' ? ` (pid ${holder.pid})` : ` (held by pid ${holder.pid})`;
103
+ }
104
+ return '';
105
+ }
106
+ function formatBusyMessage(input) {
107
+ const { spec, workspacePath, holder } = input;
108
+ const clause = formatHolderClause(holder, 'busy');
109
+ if (clause) {
110
+ return (`${spec.workspaceLabel} "${workspacePath}" is in use by another lumpcode run` +
111
+ `${clause}. Wait for it to finish or stop the daemon before running again.`);
101
112
  }
102
113
  return (`${spec.workspaceLabel} "${workspacePath}" is in use by another lumpcode run. ` +
103
114
  `Wait for it to finish or stop the daemon before running again.`);
104
115
  }
105
116
  function formatWorkspaceFileWaitMessage(input) {
106
117
  const { spec, workspacePath, holder } = input;
107
- if (holder?.lumpName && holder.pid) {
108
- return (`${spec.waitLogNoun} busy at "${workspacePath}" ` +
109
- `(held by lump "${holder.lumpName}" pid ${holder.pid}); waiting…`);
110
- }
111
- return `${spec.waitLogNoun} busy at "${workspacePath}"; waiting…`;
118
+ return (`${spec.waitLogNoun} busy at "${workspacePath}"` +
119
+ `${formatHolderClause(holder, 'held')}; waiting…`);
120
+ }
121
+ function formatWorkspaceFileStillWaitingMessage(input) {
122
+ const { spec, workspacePath, holder, elapsedMs } = input;
123
+ const elapsedSec = Math.max(1, Math.round(elapsedMs / 1000));
124
+ return (`${spec.waitLogNoun} busy at "${workspacePath}"` +
125
+ `${formatHolderClause(holder, 'held')}; still waiting (${elapsedSec}s)…`);
126
+ }
127
+ function formatWaitTimeoutMessage(input) {
128
+ const { spec, workspacePath, holder, waitTimeoutMs } = input;
129
+ return (`Waited ${waitTimeoutMs}ms for ${spec.waitLogNoun} "${workspacePath}"` +
130
+ `${formatHolderClause(holder, 'held')}; giving up.`);
131
+ }
132
+ function toBusyError(input) {
133
+ const { spec, workspacePath, holder, message, reason } = input;
134
+ return {
135
+ code: spec.busyCode,
136
+ message,
137
+ [spec.workspacePathField]: workspacePath,
138
+ ...(holder?.pid !== undefined ? { holderPid: holder.pid } : {}),
139
+ ...(holder?.lumpName !== undefined ? { holderLumpName: holder.lumpName } : {}),
140
+ ...(reason !== undefined ? { reason } : {}),
141
+ };
112
142
  }
113
143
  async function readLockHolder(lockFilePath) {
114
144
  const result = await readJsonFile({ filePath: lockFilePath, ifMissing: 'undefined' });
@@ -151,6 +181,8 @@ async function tryAcquireWorkspaceFileLockOnce(input) {
151
181
  }
152
182
  async function acquireWorkspaceFileLock(input) {
153
183
  const { spec, globalConfigFolderPath, workspacePath, lumpName, mode, projectName, logger } = input;
184
+ const waitTimeoutMs = input.waitTimeoutMs ?? WORKSPACE_LOCK_WAIT_TIMEOUT_MS;
185
+ const waitLogIntervalMs = input.waitLogIntervalMs ?? WORKSPACE_LOCK_WAIT_LOG_INTERVAL_MS;
154
186
  const normalizedWorkspacePath = path.resolve(workspacePath);
155
187
  const locksDir = workspaceLocksDirPath({ globalConfigFolderPath, spec });
156
188
  await fs.mkdir(locksDir, { recursive: true });
@@ -167,10 +199,12 @@ async function acquireWorkspaceFileLock(input) {
167
199
  ...(projectName !== undefined ? { projectName } : {}),
168
200
  };
169
201
  let loggedWait = false;
202
+ let lastWaitLogAt = 0;
203
+ const waitStartedAt = Date.now();
170
204
  for (;;) {
171
205
  const attempt = await tryAcquireWorkspaceFileLockOnce({ lockFilePath, payload, spec, logger });
172
206
  if (attempt.status === 'acquired') {
173
- const release = async () => {
207
+ const releaseAsync = async () => {
174
208
  try {
175
209
  const holder = await readLockHolder(lockFilePath);
176
210
  if (holder?.pid === process.pid) {
@@ -181,6 +215,20 @@ async function acquireWorkspaceFileLock(input) {
181
215
  // lock already gone
182
216
  }
183
217
  };
218
+ const release = Object.assign(releaseAsync, {
219
+ sync: () => {
220
+ try {
221
+ const raw = fsSync.readFileSync(lockFilePath, 'utf8');
222
+ const holder = JSON.parse(raw);
223
+ if (holder?.pid === process.pid) {
224
+ fsSync.unlinkSync(lockFilePath);
225
+ }
226
+ }
227
+ catch {
228
+ // lock already gone or unreadable
229
+ }
230
+ },
231
+ });
184
232
  return success(release);
185
233
  }
186
234
  if (attempt.status === 'stale_removed') {
@@ -188,29 +236,51 @@ async function acquireWorkspaceFileLock(input) {
188
236
  continue;
189
237
  }
190
238
  if (mode === 'fail') {
191
- return failure({
192
- code: spec.busyCode,
239
+ return failure(toBusyError({
240
+ spec,
241
+ workspacePath: normalizedWorkspacePath,
242
+ holder: attempt.holder,
193
243
  message: formatBusyMessage({
194
244
  spec,
195
245
  workspacePath: normalizedWorkspacePath,
196
246
  holder: attempt.holder,
197
247
  }),
198
- [spec.workspacePathField]: normalizedWorkspacePath,
199
- ...(attempt.holder?.pid !== undefined ? { holderPid: attempt.holder.pid } : {}),
200
- ...(attempt.holder?.lumpName !== undefined
201
- ? { holderLumpName: attempt.holder.lumpName }
202
- : {}),
203
- });
248
+ }));
204
249
  }
205
- if (!loggedWait) {
206
- logger?.info(formatWorkspaceFileWaitMessage({
250
+ const now = Date.now();
251
+ const elapsedMs = now - waitStartedAt;
252
+ if (elapsedMs >= waitTimeoutMs) {
253
+ return failure(toBusyError({
207
254
  spec,
208
255
  workspacePath: normalizedWorkspacePath,
209
256
  holder: attempt.holder,
257
+ reason: 'waitTimedOut',
258
+ message: formatWaitTimeoutMessage({
259
+ spec,
260
+ workspacePath: normalizedWorkspacePath,
261
+ holder: attempt.holder,
262
+ waitTimeoutMs,
263
+ }),
210
264
  }));
265
+ }
266
+ if (!loggedWait || now - lastWaitLogAt >= waitLogIntervalMs) {
267
+ logger?.info(loggedWait
268
+ ? formatWorkspaceFileStillWaitingMessage({
269
+ spec,
270
+ workspacePath: normalizedWorkspacePath,
271
+ holder: attempt.holder,
272
+ elapsedMs,
273
+ })
274
+ : formatWorkspaceFileWaitMessage({
275
+ spec,
276
+ workspacePath: normalizedWorkspacePath,
277
+ holder: attempt.holder,
278
+ }));
211
279
  loggedWait = true;
280
+ lastWaitLogAt = now;
212
281
  }
213
- await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_MS));
282
+ const remainingMs = waitTimeoutMs - elapsedMs;
283
+ await new Promise((resolve) => setTimeout(resolve, Math.min(WAIT_POLL_MS, Math.max(0, remainingMs))));
214
284
  }
215
285
  }
216
286
 
@@ -254,6 +324,8 @@ async function acquireGitCommonDirLock(input) {
254
324
  mode: input.lockMode,
255
325
  projectName: input.projectName,
256
326
  logger: input.logger,
327
+ waitTimeoutMs: input.waitTimeoutMs,
328
+ waitLogIntervalMs: input.waitLogIntervalMs,
257
329
  });
258
330
  }
259
331
  /** Acquire, run `fn`, always release. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumpcode/cli-utils",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Types, defineX helpers, and runtime utilities for Lumpcode lump configs and recipes",
5
5
  "keywords": [
6
6
  "lumpcode",
@@ -43,8 +43,8 @@
43
43
  "test": "vitest run"
44
44
  },
45
45
  "dependencies": {
46
- "@lumpcode/cli-types": "^0.1.0",
47
- "@lumpcode/core": "^0.1.0",
46
+ "@lumpcode/cli-types": "^0.2.0",
47
+ "@lumpcode/core": "^0.2.0",
48
48
  "js-yaml": "^5.0.0"
49
49
  },
50
50
  "devDependencies": {