@lumpcode/cli-utils 0.0.16 → 0.1.1

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/README.md CHANGED
@@ -49,7 +49,8 @@ npm run build -w=@lumpcode/cli-utils
49
49
 
50
50
  ## Runtime exports
51
51
 
52
- - `getContextStatus` — remote marker-commit status for a context
52
+ - `getContextStatus` — remote marker-commit status for a context (locked one-shot fetch)
53
+ - `getContextStatuses` — batch status map (one locked fetch, then local reads)
53
54
  - `makeGitCommitMessageFnFromLumpName` — default `LUMP:<lump> - <context>` messages
54
55
  - `getGitCommitMessage`, `getLumpCommitPrefixForLump` — commit message helpers
55
56
  - `readYamlList` — read a YAML file as a flat list (`[]` when missing or not an array)
package/dist/index.cjs CHANGED
@@ -4,6 +4,8 @@ var cliTypes = require('@lumpcode/cli-types');
4
4
  var core = require('@lumpcode/core');
5
5
  var path = require('node:path');
6
6
  var os = require('node:os');
7
+ var crypto = require('node:crypto');
8
+ var fsSync = require('node:fs');
7
9
  var fs = require('node:fs/promises');
8
10
  var jsYaml = require('js-yaml');
9
11
 
@@ -26,8 +28,13 @@ function _interopNamespaceDefault(e) {
26
28
 
27
29
  var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path);
28
30
  var os__namespace = /*#__PURE__*/_interopNamespaceDefault(os);
31
+ var crypto__namespace = /*#__PURE__*/_interopNamespaceDefault(crypto);
32
+ var fsSync__namespace = /*#__PURE__*/_interopNamespaceDefault(fsSync);
29
33
  var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs);
30
34
 
35
+ const globalConfigFolderPath = path.join(os.homedir(), '.lumpcode');
36
+ path.join(process.cwd(), '.lumpcode');
37
+
31
38
  path__namespace.join(os__namespace.homedir(), '.lumpcode', 'auth.json');
32
39
  const LUMP_COMMIT_PREFIX = "LUMP: ";
33
40
 
@@ -52,14 +59,308 @@ function makeGitCommitMessageFnFromLumpName(lumpName) {
52
59
  };
53
60
  }
54
61
 
55
- async function getContextStatus(input) {
56
- const { projectRoot, contextName, lumpName, baseBranch } = input;
57
- return core.getContextStatus({
62
+ async function readJsonFile(input) {
63
+ const { filePath, ifMissing = 'fail', missingFileFailure } = input;
64
+ let raw;
65
+ try {
66
+ raw = await fs__namespace.readFile(filePath, 'utf8');
67
+ }
68
+ catch (error) {
69
+ const code = core.nodeErrnoCode(error);
70
+ if (code === 'ENOENT') {
71
+ if (ifMissing === 'undefined') {
72
+ return core.success(undefined);
73
+ }
74
+ if (typeof ifMissing === 'object') {
75
+ return core.success(ifMissing.defaultValue);
76
+ }
77
+ return core.failure(missingFileFailure ?? `File not found: ${filePath}`);
78
+ }
79
+ return core.failure(`Cannot read ${filePath}: ${String(error)}`);
80
+ }
81
+ try {
82
+ return core.success(JSON.parse(raw));
83
+ }
84
+ catch (error) {
85
+ return core.failure(`Invalid JSON in ${filePath}: ${String(error)}`);
86
+ }
87
+ }
88
+
89
+ function resolvePrettySpace(pretty) {
90
+ if (pretty === true)
91
+ return 2;
92
+ if (typeof pretty === 'number')
93
+ return pretty;
94
+ return undefined;
95
+ }
96
+ /** Pure formatter shared with callers that write via an open handle (e.g. workspace locks). */
97
+ function formatJsonFileContent(input) {
98
+ const { data, pretty, trailingNewline = false } = input;
99
+ const space = resolvePrettySpace(pretty);
100
+ const json = space === undefined ? JSON.stringify(data) : JSON.stringify(data, null, space);
101
+ return trailingNewline ? `${json}\n` : json;
102
+ }
103
+
104
+ const WAIT_POLL_MS = 500;
105
+ function workspaceLocksDirPath(input) {
106
+ return path__namespace.join(input.globalConfigFolderPath, input.spec.locksSubdirName);
107
+ }
108
+ function workspaceLockFilePath(input) {
109
+ const normalizedPath = path__namespace.resolve(input.workspacePath);
110
+ const hash = crypto__namespace.createHash('sha256').update(normalizedPath).digest('hex');
111
+ return path__namespace.join(workspaceLocksDirPath({
112
+ globalConfigFolderPath: input.globalConfigFolderPath,
113
+ spec: input.spec,
114
+ }), `${hash}.lock.json`);
115
+ }
116
+ function formatBusyMessage(input) {
117
+ const { spec, workspacePath, holder } = input;
118
+ if (holder?.lumpName && holder.pid) {
119
+ return (`${spec.workspaceLabel} "${workspacePath}" is in use by another lumpcode run ` +
120
+ `(pid ${holder.pid}, lump "${holder.lumpName}"). Wait for it to finish or stop the daemon before running again.`);
121
+ }
122
+ if (holder?.pid) {
123
+ return (`${spec.workspaceLabel} "${workspacePath}" is in use by another lumpcode run ` +
124
+ `(pid ${holder.pid}). Wait for it to finish or stop the daemon before running again.`);
125
+ }
126
+ return (`${spec.workspaceLabel} "${workspacePath}" is in use by another lumpcode run. ` +
127
+ `Wait for it to finish or stop the daemon before running again.`);
128
+ }
129
+ function formatWorkspaceFileWaitMessage(input) {
130
+ const { spec, workspacePath, holder } = input;
131
+ if (holder?.lumpName && holder.pid) {
132
+ return (`${spec.waitLogNoun} busy at "${workspacePath}" ` +
133
+ `(held by lump "${holder.lumpName}" pid ${holder.pid}); waiting…`);
134
+ }
135
+ return `${spec.waitLogNoun} busy at "${workspacePath}"; waiting…`;
136
+ }
137
+ async function readLockHolder(lockFilePath) {
138
+ const result = await readJsonFile({ filePath: lockFilePath, ifMissing: 'undefined' });
139
+ if (!result.success || result.data === undefined) {
140
+ return undefined;
141
+ }
142
+ const parsed = result.data;
143
+ if (typeof parsed.pid !== 'number' || Number.isNaN(parsed.pid)) {
144
+ return undefined;
145
+ }
146
+ return parsed;
147
+ }
148
+ async function tryAcquireWorkspaceFileLockOnce(input) {
149
+ const { lockFilePath, payload, spec, logger } = input;
150
+ try {
151
+ const handle = await fs__namespace.open(lockFilePath, 'wx');
152
+ try {
153
+ await handle.writeFile(formatJsonFileContent({ data: payload, trailingNewline: true }), 'utf8');
154
+ }
155
+ finally {
156
+ await handle.close();
157
+ }
158
+ return { status: 'acquired' };
159
+ }
160
+ catch (e) {
161
+ const code = core.nodeErrnoCode(e);
162
+ if (code !== 'EEXIST') {
163
+ throw e;
164
+ }
165
+ }
166
+ const holder = await readLockHolder(lockFilePath);
167
+ if (holder && core.isProcessAlive(holder.pid, { onProbeError: 'alive' })) {
168
+ return { status: 'busy', holder };
169
+ }
170
+ const stalePid = holder?.pid;
171
+ logger?.warn(`Removing stale ${spec.staleLogNoun} at "${lockFilePath}"` +
172
+ (stalePid !== undefined ? ` (pid ${stalePid} is not running)` : ''));
173
+ await fs__namespace.unlink(lockFilePath).catch(() => { });
174
+ return { status: 'stale_removed' };
175
+ }
176
+ async function acquireWorkspaceFileLock(input) {
177
+ const { spec, globalConfigFolderPath, workspacePath, lumpName, mode, projectName, logger } = input;
178
+ const normalizedWorkspacePath = path__namespace.resolve(workspacePath);
179
+ const locksDir = workspaceLocksDirPath({ globalConfigFolderPath, spec });
180
+ await fs__namespace.mkdir(locksDir, { recursive: true });
181
+ const lockFilePath = workspaceLockFilePath({
182
+ globalConfigFolderPath,
183
+ workspacePath: normalizedWorkspacePath,
184
+ spec,
185
+ });
186
+ const payload = {
187
+ pid: process.pid,
188
+ lumpName,
189
+ startedAt: new Date().toISOString(),
190
+ [spec.workspacePathField]: normalizedWorkspacePath,
191
+ ...(projectName !== undefined ? { projectName } : {}),
192
+ };
193
+ let loggedWait = false;
194
+ for (;;) {
195
+ const attempt = await tryAcquireWorkspaceFileLockOnce({ lockFilePath, payload, spec, logger });
196
+ if (attempt.status === 'acquired') {
197
+ const releaseAsync = async () => {
198
+ try {
199
+ const holder = await readLockHolder(lockFilePath);
200
+ if (holder?.pid === process.pid) {
201
+ await fs__namespace.unlink(lockFilePath);
202
+ }
203
+ }
204
+ catch {
205
+ // lock already gone
206
+ }
207
+ };
208
+ const release = Object.assign(releaseAsync, {
209
+ sync: () => {
210
+ try {
211
+ const raw = fsSync__namespace.readFileSync(lockFilePath, 'utf8');
212
+ const holder = JSON.parse(raw);
213
+ if (holder?.pid === process.pid) {
214
+ fsSync__namespace.unlinkSync(lockFilePath);
215
+ }
216
+ }
217
+ catch {
218
+ // lock already gone or unreadable
219
+ }
220
+ },
221
+ });
222
+ return core.success(release);
223
+ }
224
+ if (attempt.status === 'stale_removed') {
225
+ loggedWait = false;
226
+ continue;
227
+ }
228
+ if (mode === 'fail') {
229
+ return core.failure({
230
+ code: spec.busyCode,
231
+ message: formatBusyMessage({
232
+ spec,
233
+ workspacePath: normalizedWorkspacePath,
234
+ holder: attempt.holder,
235
+ }),
236
+ [spec.workspacePathField]: normalizedWorkspacePath,
237
+ ...(attempt.holder?.pid !== undefined ? { holderPid: attempt.holder.pid } : {}),
238
+ ...(attempt.holder?.lumpName !== undefined
239
+ ? { holderLumpName: attempt.holder.lumpName }
240
+ : {}),
241
+ });
242
+ }
243
+ if (!loggedWait) {
244
+ logger?.info(formatWorkspaceFileWaitMessage({
245
+ spec,
246
+ workspacePath: normalizedWorkspacePath,
247
+ holder: attempt.holder,
248
+ }));
249
+ loggedWait = true;
250
+ }
251
+ await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_MS));
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Resolves the absolute git common directory for a worktree or main checkout
257
+ * (`git rev-parse --git-common-dir`).
258
+ */
259
+ async function resolveGitCommonDir(input) {
260
+ const { cwd } = input;
261
+ const result = await core.execAsync('git rev-parse --path-format=absolute --git-common-dir', { cwd });
262
+ if (!result.success) {
263
+ // Older git without --path-format=absolute
264
+ const fallback = await core.execAsync('git rev-parse --git-common-dir', { cwd });
265
+ if (!fallback.success) {
266
+ return core.failure(`Failed to resolve git common dir: ${fallback.data.message}`);
267
+ }
268
+ const raw = fallback.data.stdout.trim();
269
+ return core.success(path__namespace.resolve(cwd, raw));
270
+ }
271
+ return core.success(path__namespace.resolve(result.data.stdout.trim()));
272
+ }
273
+
274
+ const GIT_COMMON_DIR_LOCK_SPEC = {
275
+ locksSubdirName: 'git-common-dir-locks',
276
+ busyCode: 'gitCommonDirBusy',
277
+ workspacePathField: 'gitCommonDir',
278
+ workspaceLabel: 'Git common dir',
279
+ waitLogNoun: 'git common dir',
280
+ staleLogNoun: 'git common dir lock',
281
+ };
282
+ async function acquireGitCommonDirLock(input) {
283
+ const commonDirResult = await resolveGitCommonDir({ cwd: input.gitCwd });
284
+ if (!commonDirResult.success) {
285
+ return commonDirResult;
286
+ }
287
+ return acquireWorkspaceFileLock({
288
+ spec: GIT_COMMON_DIR_LOCK_SPEC,
289
+ globalConfigFolderPath: input.globalConfigFolderPath,
290
+ workspacePath: commonDirResult.data,
291
+ lumpName: input.lumpName,
292
+ mode: input.lockMode,
293
+ projectName: input.projectName,
294
+ logger: input.logger,
295
+ });
296
+ }
297
+ /** Acquire, run `fn`, always release. */
298
+ async function withGitCommonDirLock(input) {
299
+ const lockResult = await acquireGitCommonDirLock(input.lock);
300
+ if (!lockResult.success) {
301
+ return lockResult;
302
+ }
303
+ const releaseLock = lockResult.data;
304
+ try {
305
+ return core.success(await input.fn());
306
+ }
307
+ finally {
308
+ await releaseLock();
309
+ }
310
+ }
311
+
312
+ /**
313
+ * One locked `git fetch --prune --no-write-fetch-head` for context-status refresh.
314
+ * Uses `projectRoot` from the refresh call as `gitCwd` (overrides lock default).
315
+ */
316
+ function makeLockedRefreshRemoteTrackingRefsFn(input) {
317
+ const { gitLock } = input;
318
+ return async ({ projectRoot, remoteName }) => {
319
+ const locked = await withGitCommonDirLock({
320
+ lock: { ...gitLock, gitCwd: projectRoot },
321
+ fn: async () => core.refreshRemoteTrackingRefs({ projectRoot, remoteName }),
322
+ });
323
+ if (!locked.success) {
324
+ return core.failure(typeof locked.data === 'string' ? locked.data : locked.data.message);
325
+ }
326
+ return locked.data;
327
+ };
328
+ }
329
+
330
+ async function getContextStatuses(input) {
331
+ const { projectRoot, lumpName, baseBranch, contextNames, skipRefresh = false } = input;
332
+ const uniqueNames = [...new Set(contextNames)];
333
+ const gitCommitMessageFn = makeGitCommitMessageFnFromLumpName(lumpName);
334
+ if (!skipRefresh) {
335
+ const refreshRemoteTrackingRefsFn = makeLockedRefreshRemoteTrackingRefsFn({
336
+ gitLock: {
337
+ globalConfigFolderPath: globalConfigFolderPath,
338
+ gitCwd: projectRoot,
339
+ lumpName,
340
+ lockMode: 'wait',
341
+ },
342
+ });
343
+ const refreshResult = await refreshRemoteTrackingRefsFn({ projectRoot });
344
+ if (!refreshResult.success) {
345
+ return new Map(uniqueNames.map((name) => [name, 'toDo']));
346
+ }
347
+ }
348
+ const statuses = await Promise.all(uniqueNames.map((contextName) => core.getContextStatus({
58
349
  contextName,
59
- gitCommitMessageFn: makeGitCommitMessageFnFromLumpName(lumpName),
350
+ gitCommitMessageFn,
60
351
  projectRoot,
61
352
  baseBranch,
353
+ skipFetch: true,
354
+ })));
355
+ return new Map(uniqueNames.map((name, i) => [name, statuses[i]]));
356
+ }
357
+ async function getContextStatus(input) {
358
+ const { contextName, ...rest } = input;
359
+ const statuses = await getContextStatuses({
360
+ ...rest,
361
+ contextNames: [contextName],
62
362
  });
363
+ return statuses.get(contextName) ?? 'toDo';
63
364
  }
64
365
 
65
366
  function parseYamlList(raw) {
@@ -104,6 +405,7 @@ function normalizePromptAndSteps({ prompt, jsSteps, }) {
104
405
  }
105
406
 
106
407
  exports.getContextStatus = getContextStatus;
408
+ exports.getContextStatuses = getContextStatuses;
107
409
  exports.getGitCommitMessage = getGitCommitMessage;
108
410
  exports.getLumpCommitPrefixForLump = getLumpCommitPrefixForLump;
109
411
  exports.makeGitCommitMessageFnFromLumpName = makeGitCommitMessageFnFromLumpName;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  export * from '@lumpcode/cli-types';
2
- import { LumpVariables, CodeBasePath, MaybePromise, Maybe, Context, ContextStatus, StepVariables, PromptFnInput, PostCommandExecFn, Step, PromptFn, RunLumpInput, GitCommitMessageFn } from '@lumpcode/core';
2
+ import { ContextList, MaybePromise, LumpVariables, CodeBasePath, Maybe, Context, ContextStatus, GetContextListFnOutput, StepVariables, PromptFnInput, PostCommandExecFn, Step, PromptFn, RunLumpInput, GitCommitMessageFn } from '@lumpcode/core';
3
+
4
+ type BaseBranchFnInput = {
5
+ effectiveDiscoveryBranch: string;
6
+ /** Pre-status raw list from the context source (not todo-filtered). */
7
+ contexts: ContextList;
8
+ };
9
+ type BaseBranchFn = (input: BaseBranchFnInput) => MaybePromise<string>;
3
10
 
4
11
  type CommandTag = string;
5
12
 
@@ -7,6 +14,7 @@ type ContextMatchFn<V extends LumpVariables = LumpVariables> = (params: {
7
14
  codeBasePath: CodeBasePath;
8
15
  codeBasePaths: CodeBasePath[];
9
16
  lumpVariables: V;
17
+ discoveryBranch: string;
10
18
  }) => MaybePromise<Maybe<{
11
19
  contextName: Context['name'];
12
20
  filePathVariableName: string;
@@ -27,6 +35,17 @@ type FilePath = string;
27
35
 
28
36
  type FilePathOrString = FilePath | string;
29
37
 
38
+ /**
39
+ * Author-facing CLI context list fn. Requires concrete `discoveryBranch`.
40
+ * At the run boundary, CLI adapts this to core `GetContextListFn` (no discovery field).
41
+ */
42
+ type GetContextListFnInput<V extends LumpVariables = LumpVariables> = {
43
+ codeBasePaths: CodeBasePath[];
44
+ lumpVariables: V;
45
+ discoveryBranch: string;
46
+ };
47
+ type GetContextListFn<V extends LumpVariables = LumpVariables> = (params: GetContextListFnInput<V>) => GetContextListFnOutput;
48
+
30
49
  type MergeObjs<T, U> = Omit<T, keyof U> & U;
31
50
 
32
51
  type LumpJsConfigStepsFn<V extends LumpVariables = LumpVariables, SV extends StepVariables = StepVariables> = (input: Omit<PromptFnInput<V, SV>, 'stepVariables'>) => MaybePromise<LumpJsConfigSteps<V, SV> | LumpJsConfigStepsItem<V, SV>>;
@@ -45,14 +64,24 @@ type LumpJsConfigStep<V extends LumpVariables = LumpVariables, SV extends StepVa
45
64
  type LumpJsConfigSoloStep<V extends LumpVariables = LumpVariables, SV extends StepVariables = StepVariables> = LumpJsConfigStep<V, SV> | LumpJsConfigStep<V, SV>['promptTemplate'] | LumpJsConfigStep<V, SV>['promptFn'];
46
65
  type LumpJsConfig<V extends LumpVariables = LumpVariables, SV extends StepVariables = StepVariables> = MergeObjs<Omit<{
47
66
  [K in keyof RunLumpInput<V, SV>]?: NonNullable<RunLumpInput<V, SV>[K]> extends Function ? (RunLumpInput<V, SV>[K] | FilePath) : RunLumpInput<V, SV>[K];
48
- }, 'gitCommitMessageFn' | 'projectRoot' | 'branchFn' | 'baseBranch' | 'setupWorkspaceFn' | 'teardownWorkspaceFn' | 'gitAddCommandFn' | 'gitCommitCommandFn' | 'gitPushCommandFn'>, {
49
- baseBranch?: RunLumpInput<V, SV>['baseBranch'];
50
- /** Which integration line this lump is discovered and scheduled on (defaults to primary branch from local.json). */
67
+ }, 'gitCommitMessageFn' | 'projectRoot' | 'branchFn' | 'baseBranch' | 'setupWorkspaceFn' | 'teardownWorkspaceFn' | 'gitAddCommandFn' | 'gitCommitCommandFn' | 'gitPushCommandFn' | 'getContextListFn' | 'refreshRemoteTrackingRefsFn'>, {
68
+ /**
69
+ * Execution integration branch. Exact string, `BaseBranchFn`, or FilePath to a module.
70
+ * Omit → concrete effective discovery branch. Pattern strings are rejected at resolve.
71
+ */
72
+ baseBranch?: string | BaseBranchFn | FilePath;
73
+ /** Which integration line this lump is discovered and scheduled on (defaults to primary). Exact or git-glob. */
51
74
  discoveryBranch?: string;
75
+ /**
76
+ * Discovery rules (exact and/or git-glob). Mutually exclusive with `discoveryBranch`.
77
+ */
78
+ discoveryBranches?: string[];
52
79
  command?: LumpJsConfigStep<V, SV>['command'];
53
80
  contextListJson?: FilePath | Record<string, string>;
54
81
  contextMatchFn?: FilePath | ContextMatchFn<V>;
55
82
  contextOptionsFn?: FilePath | ContextOptionsFn;
83
+ /** Author context list fn (CLI shape with required `discoveryBranch`). */
84
+ getContextListFn?: FilePath | GetContextListFn<V>;
56
85
  disabled?: boolean | (() => MaybePromise<boolean>) | FilePath;
57
86
  maximumNumberOfConcurrentBranches?: number;
58
87
  prompt?: LumpJsConfigSoloStep<V, SV>;
@@ -62,6 +91,17 @@ type LumpJsConfig<V extends LumpVariables = LumpVariables, SV extends StepVariab
62
91
  verbose?: boolean;
63
92
  }>;
64
93
 
94
+ declare function getContextStatuses(input: {
95
+ projectRoot: string;
96
+ lumpName: string;
97
+ baseBranch: string;
98
+ contextNames: string[];
99
+ /**
100
+ * When true, skip the locked remote refresh and classify from existing
101
+ * remote-tracking refs. Caller must have refreshed already when freshness matters.
102
+ */
103
+ skipRefresh?: boolean;
104
+ }): Promise<Map<string, ContextStatus>>;
65
105
  declare function getContextStatus(input: {
66
106
  projectRoot: string;
67
107
  contextName: string;
@@ -145,5 +185,5 @@ type CodexPresetLumpVariables = {
145
185
  };
146
186
  type CodexPresetStepVariables = CodexPresetLumpVariables & PresetSessionStepVariables;
147
187
 
148
- export { getContextStatus, getGitCommitMessage, getLumpCommitPrefixForLump, makeGitCommitMessageFnFromLumpName, normalizeSteps, readYamlList };
188
+ export { getContextStatus, getContextStatuses, getGitCommitMessage, getLumpCommitPrefixForLump, makeGitCommitMessageFnFromLumpName, normalizeSteps, readYamlList };
149
189
  export type { ClaudeCodeAgentPermissions, ClaudeCodePresetLumpVariables, ClaudeCodePresetStepVariables, CodexAgentPermissions, CodexPresetLumpVariables, CodexPresetStepVariables, CopilotAgentPermissions, CopilotPresetLumpVariables, CopilotPresetStepVariables, CursorAgentPermissions, CursorPresetLumpVariables, CursorPresetStepVariables, OpenCodeAgentPermissions, OpenCodePresetLumpVariables, OpenCodePresetStepVariables, PresetSessionStepVariables };
package/dist/index.js CHANGED
@@ -1,10 +1,17 @@
1
1
  export * from '@lumpcode/cli-types';
2
- import { getContextStatus as getContextStatus$1 } from '@lumpcode/core';
2
+ import { nodeErrnoCode, success, failure, isProcessAlive, execAsync, refreshRemoteTrackingRefs, getContextStatus as getContextStatus$1 } from '@lumpcode/core';
3
3
  import * as path from 'node:path';
4
+ import path__default from 'node:path';
4
5
  import * as os from 'node:os';
6
+ import os__default from 'node:os';
7
+ import * as crypto from 'node:crypto';
8
+ import * as fsSync from 'node:fs';
5
9
  import * as fs from 'node:fs/promises';
6
10
  import { load } from 'js-yaml';
7
11
 
12
+ const globalConfigFolderPath = path__default.join(os__default.homedir(), '.lumpcode');
13
+ path__default.join(process.cwd(), '.lumpcode');
14
+
8
15
  path.join(os.homedir(), '.lumpcode', 'auth.json');
9
16
  const LUMP_COMMIT_PREFIX = "LUMP: ";
10
17
 
@@ -29,14 +36,308 @@ function makeGitCommitMessageFnFromLumpName(lumpName) {
29
36
  };
30
37
  }
31
38
 
32
- async function getContextStatus(input) {
33
- const { projectRoot, contextName, lumpName, baseBranch } = input;
34
- return getContextStatus$1({
39
+ async function readJsonFile(input) {
40
+ const { filePath, ifMissing = 'fail', missingFileFailure } = input;
41
+ let raw;
42
+ try {
43
+ raw = await fs.readFile(filePath, 'utf8');
44
+ }
45
+ catch (error) {
46
+ const code = nodeErrnoCode(error);
47
+ if (code === 'ENOENT') {
48
+ if (ifMissing === 'undefined') {
49
+ return success(undefined);
50
+ }
51
+ if (typeof ifMissing === 'object') {
52
+ return success(ifMissing.defaultValue);
53
+ }
54
+ return failure(missingFileFailure ?? `File not found: ${filePath}`);
55
+ }
56
+ return failure(`Cannot read ${filePath}: ${String(error)}`);
57
+ }
58
+ try {
59
+ return success(JSON.parse(raw));
60
+ }
61
+ catch (error) {
62
+ return failure(`Invalid JSON in ${filePath}: ${String(error)}`);
63
+ }
64
+ }
65
+
66
+ function resolvePrettySpace(pretty) {
67
+ if (pretty === true)
68
+ return 2;
69
+ if (typeof pretty === 'number')
70
+ return pretty;
71
+ return undefined;
72
+ }
73
+ /** Pure formatter shared with callers that write via an open handle (e.g. workspace locks). */
74
+ function formatJsonFileContent(input) {
75
+ const { data, pretty, trailingNewline = false } = input;
76
+ const space = resolvePrettySpace(pretty);
77
+ const json = space === undefined ? JSON.stringify(data) : JSON.stringify(data, null, space);
78
+ return trailingNewline ? `${json}\n` : json;
79
+ }
80
+
81
+ const WAIT_POLL_MS = 500;
82
+ function workspaceLocksDirPath(input) {
83
+ return path.join(input.globalConfigFolderPath, input.spec.locksSubdirName);
84
+ }
85
+ function workspaceLockFilePath(input) {
86
+ const normalizedPath = path.resolve(input.workspacePath);
87
+ const hash = crypto.createHash('sha256').update(normalizedPath).digest('hex');
88
+ return path.join(workspaceLocksDirPath({
89
+ globalConfigFolderPath: input.globalConfigFolderPath,
90
+ spec: input.spec,
91
+ }), `${hash}.lock.json`);
92
+ }
93
+ function formatBusyMessage(input) {
94
+ const { spec, workspacePath, holder } = input;
95
+ if (holder?.lumpName && holder.pid) {
96
+ return (`${spec.workspaceLabel} "${workspacePath}" is in use by another lumpcode run ` +
97
+ `(pid ${holder.pid}, lump "${holder.lumpName}"). Wait for it to finish or stop the daemon before running again.`);
98
+ }
99
+ if (holder?.pid) {
100
+ return (`${spec.workspaceLabel} "${workspacePath}" is in use by another lumpcode run ` +
101
+ `(pid ${holder.pid}). Wait for it to finish or stop the daemon before running again.`);
102
+ }
103
+ return (`${spec.workspaceLabel} "${workspacePath}" is in use by another lumpcode run. ` +
104
+ `Wait for it to finish or stop the daemon before running again.`);
105
+ }
106
+ function formatWorkspaceFileWaitMessage(input) {
107
+ const { spec, workspacePath, holder } = input;
108
+ if (holder?.lumpName && holder.pid) {
109
+ return (`${spec.waitLogNoun} busy at "${workspacePath}" ` +
110
+ `(held by lump "${holder.lumpName}" pid ${holder.pid}); waiting…`);
111
+ }
112
+ return `${spec.waitLogNoun} busy at "${workspacePath}"; waiting…`;
113
+ }
114
+ async function readLockHolder(lockFilePath) {
115
+ const result = await readJsonFile({ filePath: lockFilePath, ifMissing: 'undefined' });
116
+ if (!result.success || result.data === undefined) {
117
+ return undefined;
118
+ }
119
+ const parsed = result.data;
120
+ if (typeof parsed.pid !== 'number' || Number.isNaN(parsed.pid)) {
121
+ return undefined;
122
+ }
123
+ return parsed;
124
+ }
125
+ async function tryAcquireWorkspaceFileLockOnce(input) {
126
+ const { lockFilePath, payload, spec, logger } = input;
127
+ try {
128
+ const handle = await fs.open(lockFilePath, 'wx');
129
+ try {
130
+ await handle.writeFile(formatJsonFileContent({ data: payload, trailingNewline: true }), 'utf8');
131
+ }
132
+ finally {
133
+ await handle.close();
134
+ }
135
+ return { status: 'acquired' };
136
+ }
137
+ catch (e) {
138
+ const code = nodeErrnoCode(e);
139
+ if (code !== 'EEXIST') {
140
+ throw e;
141
+ }
142
+ }
143
+ const holder = await readLockHolder(lockFilePath);
144
+ if (holder && isProcessAlive(holder.pid, { onProbeError: 'alive' })) {
145
+ return { status: 'busy', holder };
146
+ }
147
+ const stalePid = holder?.pid;
148
+ logger?.warn(`Removing stale ${spec.staleLogNoun} at "${lockFilePath}"` +
149
+ (stalePid !== undefined ? ` (pid ${stalePid} is not running)` : ''));
150
+ await fs.unlink(lockFilePath).catch(() => { });
151
+ return { status: 'stale_removed' };
152
+ }
153
+ async function acquireWorkspaceFileLock(input) {
154
+ const { spec, globalConfigFolderPath, workspacePath, lumpName, mode, projectName, logger } = input;
155
+ const normalizedWorkspacePath = path.resolve(workspacePath);
156
+ const locksDir = workspaceLocksDirPath({ globalConfigFolderPath, spec });
157
+ await fs.mkdir(locksDir, { recursive: true });
158
+ const lockFilePath = workspaceLockFilePath({
159
+ globalConfigFolderPath,
160
+ workspacePath: normalizedWorkspacePath,
161
+ spec,
162
+ });
163
+ const payload = {
164
+ pid: process.pid,
165
+ lumpName,
166
+ startedAt: new Date().toISOString(),
167
+ [spec.workspacePathField]: normalizedWorkspacePath,
168
+ ...(projectName !== undefined ? { projectName } : {}),
169
+ };
170
+ let loggedWait = false;
171
+ for (;;) {
172
+ const attempt = await tryAcquireWorkspaceFileLockOnce({ lockFilePath, payload, spec, logger });
173
+ if (attempt.status === 'acquired') {
174
+ const releaseAsync = async () => {
175
+ try {
176
+ const holder = await readLockHolder(lockFilePath);
177
+ if (holder?.pid === process.pid) {
178
+ await fs.unlink(lockFilePath);
179
+ }
180
+ }
181
+ catch {
182
+ // lock already gone
183
+ }
184
+ };
185
+ const release = Object.assign(releaseAsync, {
186
+ sync: () => {
187
+ try {
188
+ const raw = fsSync.readFileSync(lockFilePath, 'utf8');
189
+ const holder = JSON.parse(raw);
190
+ if (holder?.pid === process.pid) {
191
+ fsSync.unlinkSync(lockFilePath);
192
+ }
193
+ }
194
+ catch {
195
+ // lock already gone or unreadable
196
+ }
197
+ },
198
+ });
199
+ return success(release);
200
+ }
201
+ if (attempt.status === 'stale_removed') {
202
+ loggedWait = false;
203
+ continue;
204
+ }
205
+ if (mode === 'fail') {
206
+ return failure({
207
+ code: spec.busyCode,
208
+ message: formatBusyMessage({
209
+ spec,
210
+ workspacePath: normalizedWorkspacePath,
211
+ holder: attempt.holder,
212
+ }),
213
+ [spec.workspacePathField]: normalizedWorkspacePath,
214
+ ...(attempt.holder?.pid !== undefined ? { holderPid: attempt.holder.pid } : {}),
215
+ ...(attempt.holder?.lumpName !== undefined
216
+ ? { holderLumpName: attempt.holder.lumpName }
217
+ : {}),
218
+ });
219
+ }
220
+ if (!loggedWait) {
221
+ logger?.info(formatWorkspaceFileWaitMessage({
222
+ spec,
223
+ workspacePath: normalizedWorkspacePath,
224
+ holder: attempt.holder,
225
+ }));
226
+ loggedWait = true;
227
+ }
228
+ await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_MS));
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Resolves the absolute git common directory for a worktree or main checkout
234
+ * (`git rev-parse --git-common-dir`).
235
+ */
236
+ async function resolveGitCommonDir(input) {
237
+ const { cwd } = input;
238
+ const result = await execAsync('git rev-parse --path-format=absolute --git-common-dir', { cwd });
239
+ if (!result.success) {
240
+ // Older git without --path-format=absolute
241
+ const fallback = await execAsync('git rev-parse --git-common-dir', { cwd });
242
+ if (!fallback.success) {
243
+ return failure(`Failed to resolve git common dir: ${fallback.data.message}`);
244
+ }
245
+ const raw = fallback.data.stdout.trim();
246
+ return success(path.resolve(cwd, raw));
247
+ }
248
+ return success(path.resolve(result.data.stdout.trim()));
249
+ }
250
+
251
+ const GIT_COMMON_DIR_LOCK_SPEC = {
252
+ locksSubdirName: 'git-common-dir-locks',
253
+ busyCode: 'gitCommonDirBusy',
254
+ workspacePathField: 'gitCommonDir',
255
+ workspaceLabel: 'Git common dir',
256
+ waitLogNoun: 'git common dir',
257
+ staleLogNoun: 'git common dir lock',
258
+ };
259
+ async function acquireGitCommonDirLock(input) {
260
+ const commonDirResult = await resolveGitCommonDir({ cwd: input.gitCwd });
261
+ if (!commonDirResult.success) {
262
+ return commonDirResult;
263
+ }
264
+ return acquireWorkspaceFileLock({
265
+ spec: GIT_COMMON_DIR_LOCK_SPEC,
266
+ globalConfigFolderPath: input.globalConfigFolderPath,
267
+ workspacePath: commonDirResult.data,
268
+ lumpName: input.lumpName,
269
+ mode: input.lockMode,
270
+ projectName: input.projectName,
271
+ logger: input.logger,
272
+ });
273
+ }
274
+ /** Acquire, run `fn`, always release. */
275
+ async function withGitCommonDirLock(input) {
276
+ const lockResult = await acquireGitCommonDirLock(input.lock);
277
+ if (!lockResult.success) {
278
+ return lockResult;
279
+ }
280
+ const releaseLock = lockResult.data;
281
+ try {
282
+ return success(await input.fn());
283
+ }
284
+ finally {
285
+ await releaseLock();
286
+ }
287
+ }
288
+
289
+ /**
290
+ * One locked `git fetch --prune --no-write-fetch-head` for context-status refresh.
291
+ * Uses `projectRoot` from the refresh call as `gitCwd` (overrides lock default).
292
+ */
293
+ function makeLockedRefreshRemoteTrackingRefsFn(input) {
294
+ const { gitLock } = input;
295
+ return async ({ projectRoot, remoteName }) => {
296
+ const locked = await withGitCommonDirLock({
297
+ lock: { ...gitLock, gitCwd: projectRoot },
298
+ fn: async () => refreshRemoteTrackingRefs({ projectRoot, remoteName }),
299
+ });
300
+ if (!locked.success) {
301
+ return failure(typeof locked.data === 'string' ? locked.data : locked.data.message);
302
+ }
303
+ return locked.data;
304
+ };
305
+ }
306
+
307
+ async function getContextStatuses(input) {
308
+ const { projectRoot, lumpName, baseBranch, contextNames, skipRefresh = false } = input;
309
+ const uniqueNames = [...new Set(contextNames)];
310
+ const gitCommitMessageFn = makeGitCommitMessageFnFromLumpName(lumpName);
311
+ if (!skipRefresh) {
312
+ const refreshRemoteTrackingRefsFn = makeLockedRefreshRemoteTrackingRefsFn({
313
+ gitLock: {
314
+ globalConfigFolderPath: globalConfigFolderPath,
315
+ gitCwd: projectRoot,
316
+ lumpName,
317
+ lockMode: 'wait',
318
+ },
319
+ });
320
+ const refreshResult = await refreshRemoteTrackingRefsFn({ projectRoot });
321
+ if (!refreshResult.success) {
322
+ return new Map(uniqueNames.map((name) => [name, 'toDo']));
323
+ }
324
+ }
325
+ const statuses = await Promise.all(uniqueNames.map((contextName) => getContextStatus$1({
35
326
  contextName,
36
- gitCommitMessageFn: makeGitCommitMessageFnFromLumpName(lumpName),
327
+ gitCommitMessageFn,
37
328
  projectRoot,
38
329
  baseBranch,
330
+ skipFetch: true,
331
+ })));
332
+ return new Map(uniqueNames.map((name, i) => [name, statuses[i]]));
333
+ }
334
+ async function getContextStatus(input) {
335
+ const { contextName, ...rest } = input;
336
+ const statuses = await getContextStatuses({
337
+ ...rest,
338
+ contextNames: [contextName],
39
339
  });
340
+ return statuses.get(contextName) ?? 'toDo';
40
341
  }
41
342
 
42
343
  function parseYamlList(raw) {
@@ -80,4 +381,4 @@ function normalizePromptAndSteps({ prompt, jsSteps, }) {
80
381
  return { prompt, jsSteps };
81
382
  }
82
383
 
83
- export { getContextStatus, getGitCommitMessage, getLumpCommitPrefixForLump, makeGitCommitMessageFnFromLumpName, normalizeSteps, readYamlList };
384
+ export { getContextStatus, getContextStatuses, getGitCommitMessage, getLumpCommitPrefixForLump, makeGitCommitMessageFnFromLumpName, normalizeSteps, readYamlList };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumpcode/cli-utils",
3
- "version": "0.0.16",
3
+ "version": "0.1.1",
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.0.16",
47
- "@lumpcode/core": "^0.0.16",
46
+ "@lumpcode/cli-types": "^0.1.1",
47
+ "@lumpcode/core": "^0.1.1",
48
48
  "js-yaml": "^5.0.0"
49
49
  },
50
50
  "devDependencies": {