@lumpcode/cli-utils 0.0.16 → 0.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.
- package/README.md +2 -1
- package/dist/index.cjs +290 -4
- package/dist/index.d.ts +45 -5
- package/dist/index.js +292 -6
- package/package.json +3 -3
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,7 @@ 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');
|
|
7
8
|
var fs = require('node:fs/promises');
|
|
8
9
|
var jsYaml = require('js-yaml');
|
|
9
10
|
|
|
@@ -26,8 +27,12 @@ function _interopNamespaceDefault(e) {
|
|
|
26
27
|
|
|
27
28
|
var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path);
|
|
28
29
|
var os__namespace = /*#__PURE__*/_interopNamespaceDefault(os);
|
|
30
|
+
var crypto__namespace = /*#__PURE__*/_interopNamespaceDefault(crypto);
|
|
29
31
|
var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs);
|
|
30
32
|
|
|
33
|
+
const globalConfigFolderPath = path.join(os.homedir(), '.lumpcode');
|
|
34
|
+
path.join(process.cwd(), '.lumpcode');
|
|
35
|
+
|
|
31
36
|
path__namespace.join(os__namespace.homedir(), '.lumpcode', 'auth.json');
|
|
32
37
|
const LUMP_COMMIT_PREFIX = "LUMP: ";
|
|
33
38
|
|
|
@@ -52,14 +57,294 @@ function makeGitCommitMessageFnFromLumpName(lumpName) {
|
|
|
52
57
|
};
|
|
53
58
|
}
|
|
54
59
|
|
|
55
|
-
async function
|
|
56
|
-
const {
|
|
57
|
-
|
|
60
|
+
async function readJsonFile(input) {
|
|
61
|
+
const { filePath, ifMissing = 'fail', missingFileFailure } = input;
|
|
62
|
+
let raw;
|
|
63
|
+
try {
|
|
64
|
+
raw = await fs__namespace.readFile(filePath, 'utf8');
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
const code = core.nodeErrnoCode(error);
|
|
68
|
+
if (code === 'ENOENT') {
|
|
69
|
+
if (ifMissing === 'undefined') {
|
|
70
|
+
return core.success(undefined);
|
|
71
|
+
}
|
|
72
|
+
if (typeof ifMissing === 'object') {
|
|
73
|
+
return core.success(ifMissing.defaultValue);
|
|
74
|
+
}
|
|
75
|
+
return core.failure(missingFileFailure ?? `File not found: ${filePath}`);
|
|
76
|
+
}
|
|
77
|
+
return core.failure(`Cannot read ${filePath}: ${String(error)}`);
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
return core.success(JSON.parse(raw));
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
return core.failure(`Invalid JSON in ${filePath}: ${String(error)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resolvePrettySpace(pretty) {
|
|
88
|
+
if (pretty === true)
|
|
89
|
+
return 2;
|
|
90
|
+
if (typeof pretty === 'number')
|
|
91
|
+
return pretty;
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
/** Pure formatter shared with callers that write via an open handle (e.g. workspace locks). */
|
|
95
|
+
function formatJsonFileContent(input) {
|
|
96
|
+
const { data, pretty, trailingNewline = false } = input;
|
|
97
|
+
const space = resolvePrettySpace(pretty);
|
|
98
|
+
const json = space === undefined ? JSON.stringify(data) : JSON.stringify(data, null, space);
|
|
99
|
+
return trailingNewline ? `${json}\n` : json;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const WAIT_POLL_MS = 500;
|
|
103
|
+
function workspaceLocksDirPath(input) {
|
|
104
|
+
return path__namespace.join(input.globalConfigFolderPath, input.spec.locksSubdirName);
|
|
105
|
+
}
|
|
106
|
+
function workspaceLockFilePath(input) {
|
|
107
|
+
const normalizedPath = path__namespace.resolve(input.workspacePath);
|
|
108
|
+
const hash = crypto__namespace.createHash('sha256').update(normalizedPath).digest('hex');
|
|
109
|
+
return path__namespace.join(workspaceLocksDirPath({
|
|
110
|
+
globalConfigFolderPath: input.globalConfigFolderPath,
|
|
111
|
+
spec: input.spec,
|
|
112
|
+
}), `${hash}.lock.json`);
|
|
113
|
+
}
|
|
114
|
+
function formatBusyMessage(input) {
|
|
115
|
+
const { spec, workspacePath, holder } = input;
|
|
116
|
+
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.`);
|
|
119
|
+
}
|
|
120
|
+
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.`);
|
|
123
|
+
}
|
|
124
|
+
return (`${spec.workspaceLabel} "${workspacePath}" is in use by another lumpcode run. ` +
|
|
125
|
+
`Wait for it to finish or stop the daemon before running again.`);
|
|
126
|
+
}
|
|
127
|
+
function formatWorkspaceFileWaitMessage(input) {
|
|
128
|
+
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…`;
|
|
134
|
+
}
|
|
135
|
+
async function readLockHolder(lockFilePath) {
|
|
136
|
+
const result = await readJsonFile({ filePath: lockFilePath, ifMissing: 'undefined' });
|
|
137
|
+
if (!result.success || result.data === undefined) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
const parsed = result.data;
|
|
141
|
+
if (typeof parsed.pid !== 'number' || Number.isNaN(parsed.pid)) {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
return parsed;
|
|
145
|
+
}
|
|
146
|
+
async function tryAcquireWorkspaceFileLockOnce(input) {
|
|
147
|
+
const { lockFilePath, payload, spec, logger } = input;
|
|
148
|
+
try {
|
|
149
|
+
const handle = await fs__namespace.open(lockFilePath, 'wx');
|
|
150
|
+
try {
|
|
151
|
+
await handle.writeFile(formatJsonFileContent({ data: payload, trailingNewline: true }), 'utf8');
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
await handle.close();
|
|
155
|
+
}
|
|
156
|
+
return { status: 'acquired' };
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
const code = core.nodeErrnoCode(e);
|
|
160
|
+
if (code !== 'EEXIST') {
|
|
161
|
+
throw e;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const holder = await readLockHolder(lockFilePath);
|
|
165
|
+
if (holder && core.isProcessAlive(holder.pid, { onProbeError: 'alive' })) {
|
|
166
|
+
return { status: 'busy', holder };
|
|
167
|
+
}
|
|
168
|
+
const stalePid = holder?.pid;
|
|
169
|
+
logger?.warn(`Removing stale ${spec.staleLogNoun} at "${lockFilePath}"` +
|
|
170
|
+
(stalePid !== undefined ? ` (pid ${stalePid} is not running)` : ''));
|
|
171
|
+
await fs__namespace.unlink(lockFilePath).catch(() => { });
|
|
172
|
+
return { status: 'stale_removed' };
|
|
173
|
+
}
|
|
174
|
+
async function acquireWorkspaceFileLock(input) {
|
|
175
|
+
const { spec, globalConfigFolderPath, workspacePath, lumpName, mode, projectName, logger } = input;
|
|
176
|
+
const normalizedWorkspacePath = path__namespace.resolve(workspacePath);
|
|
177
|
+
const locksDir = workspaceLocksDirPath({ globalConfigFolderPath, spec });
|
|
178
|
+
await fs__namespace.mkdir(locksDir, { recursive: true });
|
|
179
|
+
const lockFilePath = workspaceLockFilePath({
|
|
180
|
+
globalConfigFolderPath,
|
|
181
|
+
workspacePath: normalizedWorkspacePath,
|
|
182
|
+
spec,
|
|
183
|
+
});
|
|
184
|
+
const payload = {
|
|
185
|
+
pid: process.pid,
|
|
186
|
+
lumpName,
|
|
187
|
+
startedAt: new Date().toISOString(),
|
|
188
|
+
[spec.workspacePathField]: normalizedWorkspacePath,
|
|
189
|
+
...(projectName !== undefined ? { projectName } : {}),
|
|
190
|
+
};
|
|
191
|
+
let loggedWait = false;
|
|
192
|
+
for (;;) {
|
|
193
|
+
const attempt = await tryAcquireWorkspaceFileLockOnce({ lockFilePath, payload, spec, logger });
|
|
194
|
+
if (attempt.status === 'acquired') {
|
|
195
|
+
const release = async () => {
|
|
196
|
+
try {
|
|
197
|
+
const holder = await readLockHolder(lockFilePath);
|
|
198
|
+
if (holder?.pid === process.pid) {
|
|
199
|
+
await fs__namespace.unlink(lockFilePath);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// lock already gone
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
return core.success(release);
|
|
207
|
+
}
|
|
208
|
+
if (attempt.status === 'stale_removed') {
|
|
209
|
+
loggedWait = false;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (mode === 'fail') {
|
|
213
|
+
return core.failure({
|
|
214
|
+
code: spec.busyCode,
|
|
215
|
+
message: formatBusyMessage({
|
|
216
|
+
spec,
|
|
217
|
+
workspacePath: normalizedWorkspacePath,
|
|
218
|
+
holder: attempt.holder,
|
|
219
|
+
}),
|
|
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
|
+
});
|
|
226
|
+
}
|
|
227
|
+
if (!loggedWait) {
|
|
228
|
+
logger?.info(formatWorkspaceFileWaitMessage({
|
|
229
|
+
spec,
|
|
230
|
+
workspacePath: normalizedWorkspacePath,
|
|
231
|
+
holder: attempt.holder,
|
|
232
|
+
}));
|
|
233
|
+
loggedWait = true;
|
|
234
|
+
}
|
|
235
|
+
await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_MS));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Resolves the absolute git common directory for a worktree or main checkout
|
|
241
|
+
* (`git rev-parse --git-common-dir`).
|
|
242
|
+
*/
|
|
243
|
+
async function resolveGitCommonDir(input) {
|
|
244
|
+
const { cwd } = input;
|
|
245
|
+
const result = await core.execAsync('git rev-parse --path-format=absolute --git-common-dir', { cwd });
|
|
246
|
+
if (!result.success) {
|
|
247
|
+
// Older git without --path-format=absolute
|
|
248
|
+
const fallback = await core.execAsync('git rev-parse --git-common-dir', { cwd });
|
|
249
|
+
if (!fallback.success) {
|
|
250
|
+
return core.failure(`Failed to resolve git common dir: ${fallback.data.message}`);
|
|
251
|
+
}
|
|
252
|
+
const raw = fallback.data.stdout.trim();
|
|
253
|
+
return core.success(path__namespace.resolve(cwd, raw));
|
|
254
|
+
}
|
|
255
|
+
return core.success(path__namespace.resolve(result.data.stdout.trim()));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const GIT_COMMON_DIR_LOCK_SPEC = {
|
|
259
|
+
locksSubdirName: 'git-common-dir-locks',
|
|
260
|
+
busyCode: 'gitCommonDirBusy',
|
|
261
|
+
workspacePathField: 'gitCommonDir',
|
|
262
|
+
workspaceLabel: 'Git common dir',
|
|
263
|
+
waitLogNoun: 'git common dir',
|
|
264
|
+
staleLogNoun: 'git common dir lock',
|
|
265
|
+
};
|
|
266
|
+
async function acquireGitCommonDirLock(input) {
|
|
267
|
+
const commonDirResult = await resolveGitCommonDir({ cwd: input.gitCwd });
|
|
268
|
+
if (!commonDirResult.success) {
|
|
269
|
+
return commonDirResult;
|
|
270
|
+
}
|
|
271
|
+
return acquireWorkspaceFileLock({
|
|
272
|
+
spec: GIT_COMMON_DIR_LOCK_SPEC,
|
|
273
|
+
globalConfigFolderPath: input.globalConfigFolderPath,
|
|
274
|
+
workspacePath: commonDirResult.data,
|
|
275
|
+
lumpName: input.lumpName,
|
|
276
|
+
mode: input.lockMode,
|
|
277
|
+
projectName: input.projectName,
|
|
278
|
+
logger: input.logger,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
/** Acquire, run `fn`, always release. */
|
|
282
|
+
async function withGitCommonDirLock(input) {
|
|
283
|
+
const lockResult = await acquireGitCommonDirLock(input.lock);
|
|
284
|
+
if (!lockResult.success) {
|
|
285
|
+
return lockResult;
|
|
286
|
+
}
|
|
287
|
+
const releaseLock = lockResult.data;
|
|
288
|
+
try {
|
|
289
|
+
return core.success(await input.fn());
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
await releaseLock();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* One locked `git fetch --prune --no-write-fetch-head` for context-status refresh.
|
|
298
|
+
* Uses `projectRoot` from the refresh call as `gitCwd` (overrides lock default).
|
|
299
|
+
*/
|
|
300
|
+
function makeLockedRefreshRemoteTrackingRefsFn(input) {
|
|
301
|
+
const { gitLock } = input;
|
|
302
|
+
return async ({ projectRoot, remoteName }) => {
|
|
303
|
+
const locked = await withGitCommonDirLock({
|
|
304
|
+
lock: { ...gitLock, gitCwd: projectRoot },
|
|
305
|
+
fn: async () => core.refreshRemoteTrackingRefs({ projectRoot, remoteName }),
|
|
306
|
+
});
|
|
307
|
+
if (!locked.success) {
|
|
308
|
+
return core.failure(typeof locked.data === 'string' ? locked.data : locked.data.message);
|
|
309
|
+
}
|
|
310
|
+
return locked.data;
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function getContextStatuses(input) {
|
|
315
|
+
const { projectRoot, lumpName, baseBranch, contextNames, skipRefresh = false } = input;
|
|
316
|
+
const uniqueNames = [...new Set(contextNames)];
|
|
317
|
+
const gitCommitMessageFn = makeGitCommitMessageFnFromLumpName(lumpName);
|
|
318
|
+
if (!skipRefresh) {
|
|
319
|
+
const refreshRemoteTrackingRefsFn = makeLockedRefreshRemoteTrackingRefsFn({
|
|
320
|
+
gitLock: {
|
|
321
|
+
globalConfigFolderPath: globalConfigFolderPath,
|
|
322
|
+
gitCwd: projectRoot,
|
|
323
|
+
lumpName,
|
|
324
|
+
lockMode: 'wait',
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
const refreshResult = await refreshRemoteTrackingRefsFn({ projectRoot });
|
|
328
|
+
if (!refreshResult.success) {
|
|
329
|
+
return new Map(uniqueNames.map((name) => [name, 'toDo']));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const statuses = await Promise.all(uniqueNames.map((contextName) => core.getContextStatus({
|
|
58
333
|
contextName,
|
|
59
|
-
gitCommitMessageFn
|
|
334
|
+
gitCommitMessageFn,
|
|
60
335
|
projectRoot,
|
|
61
336
|
baseBranch,
|
|
337
|
+
skipFetch: true,
|
|
338
|
+
})));
|
|
339
|
+
return new Map(uniqueNames.map((name, i) => [name, statuses[i]]));
|
|
340
|
+
}
|
|
341
|
+
async function getContextStatus(input) {
|
|
342
|
+
const { contextName, ...rest } = input;
|
|
343
|
+
const statuses = await getContextStatuses({
|
|
344
|
+
...rest,
|
|
345
|
+
contextNames: [contextName],
|
|
62
346
|
});
|
|
347
|
+
return statuses.get(contextName) ?? 'toDo';
|
|
63
348
|
}
|
|
64
349
|
|
|
65
350
|
function parseYamlList(raw) {
|
|
@@ -104,6 +389,7 @@ function normalizePromptAndSteps({ prompt, jsSteps, }) {
|
|
|
104
389
|
}
|
|
105
390
|
|
|
106
391
|
exports.getContextStatus = getContextStatus;
|
|
392
|
+
exports.getContextStatuses = getContextStatuses;
|
|
107
393
|
exports.getGitCommitMessage = getGitCommitMessage;
|
|
108
394
|
exports.getLumpCommitPrefixForLump = getLumpCommitPrefixForLump;
|
|
109
395
|
exports.makeGitCommitMessageFnFromLumpName = makeGitCommitMessageFnFromLumpName;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
export * from '@lumpcode/cli-types';
|
|
2
|
-
import { LumpVariables, CodeBasePath,
|
|
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
|
-
|
|
50
|
-
|
|
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,16 @@
|
|
|
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';
|
|
5
8
|
import * as fs from 'node:fs/promises';
|
|
6
9
|
import { load } from 'js-yaml';
|
|
7
10
|
|
|
11
|
+
const globalConfigFolderPath = path__default.join(os__default.homedir(), '.lumpcode');
|
|
12
|
+
path__default.join(process.cwd(), '.lumpcode');
|
|
13
|
+
|
|
8
14
|
path.join(os.homedir(), '.lumpcode', 'auth.json');
|
|
9
15
|
const LUMP_COMMIT_PREFIX = "LUMP: ";
|
|
10
16
|
|
|
@@ -29,14 +35,294 @@ function makeGitCommitMessageFnFromLumpName(lumpName) {
|
|
|
29
35
|
};
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
async function
|
|
33
|
-
const {
|
|
34
|
-
|
|
38
|
+
async function readJsonFile(input) {
|
|
39
|
+
const { filePath, ifMissing = 'fail', missingFileFailure } = input;
|
|
40
|
+
let raw;
|
|
41
|
+
try {
|
|
42
|
+
raw = await fs.readFile(filePath, 'utf8');
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
const code = nodeErrnoCode(error);
|
|
46
|
+
if (code === 'ENOENT') {
|
|
47
|
+
if (ifMissing === 'undefined') {
|
|
48
|
+
return success(undefined);
|
|
49
|
+
}
|
|
50
|
+
if (typeof ifMissing === 'object') {
|
|
51
|
+
return success(ifMissing.defaultValue);
|
|
52
|
+
}
|
|
53
|
+
return failure(missingFileFailure ?? `File not found: ${filePath}`);
|
|
54
|
+
}
|
|
55
|
+
return failure(`Cannot read ${filePath}: ${String(error)}`);
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
return success(JSON.parse(raw));
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
return failure(`Invalid JSON in ${filePath}: ${String(error)}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolvePrettySpace(pretty) {
|
|
66
|
+
if (pretty === true)
|
|
67
|
+
return 2;
|
|
68
|
+
if (typeof pretty === 'number')
|
|
69
|
+
return pretty;
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
/** Pure formatter shared with callers that write via an open handle (e.g. workspace locks). */
|
|
73
|
+
function formatJsonFileContent(input) {
|
|
74
|
+
const { data, pretty, trailingNewline = false } = input;
|
|
75
|
+
const space = resolvePrettySpace(pretty);
|
|
76
|
+
const json = space === undefined ? JSON.stringify(data) : JSON.stringify(data, null, space);
|
|
77
|
+
return trailingNewline ? `${json}\n` : json;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const WAIT_POLL_MS = 500;
|
|
81
|
+
function workspaceLocksDirPath(input) {
|
|
82
|
+
return path.join(input.globalConfigFolderPath, input.spec.locksSubdirName);
|
|
83
|
+
}
|
|
84
|
+
function workspaceLockFilePath(input) {
|
|
85
|
+
const normalizedPath = path.resolve(input.workspacePath);
|
|
86
|
+
const hash = crypto.createHash('sha256').update(normalizedPath).digest('hex');
|
|
87
|
+
return path.join(workspaceLocksDirPath({
|
|
88
|
+
globalConfigFolderPath: input.globalConfigFolderPath,
|
|
89
|
+
spec: input.spec,
|
|
90
|
+
}), `${hash}.lock.json`);
|
|
91
|
+
}
|
|
92
|
+
function formatBusyMessage(input) {
|
|
93
|
+
const { spec, workspacePath, holder } = input;
|
|
94
|
+
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
|
+
}
|
|
98
|
+
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.`);
|
|
101
|
+
}
|
|
102
|
+
return (`${spec.workspaceLabel} "${workspacePath}" is in use by another lumpcode run. ` +
|
|
103
|
+
`Wait for it to finish or stop the daemon before running again.`);
|
|
104
|
+
}
|
|
105
|
+
function formatWorkspaceFileWaitMessage(input) {
|
|
106
|
+
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…`;
|
|
112
|
+
}
|
|
113
|
+
async function readLockHolder(lockFilePath) {
|
|
114
|
+
const result = await readJsonFile({ filePath: lockFilePath, ifMissing: 'undefined' });
|
|
115
|
+
if (!result.success || result.data === undefined) {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
const parsed = result.data;
|
|
119
|
+
if (typeof parsed.pid !== 'number' || Number.isNaN(parsed.pid)) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
return parsed;
|
|
123
|
+
}
|
|
124
|
+
async function tryAcquireWorkspaceFileLockOnce(input) {
|
|
125
|
+
const { lockFilePath, payload, spec, logger } = input;
|
|
126
|
+
try {
|
|
127
|
+
const handle = await fs.open(lockFilePath, 'wx');
|
|
128
|
+
try {
|
|
129
|
+
await handle.writeFile(formatJsonFileContent({ data: payload, trailingNewline: true }), 'utf8');
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
await handle.close();
|
|
133
|
+
}
|
|
134
|
+
return { status: 'acquired' };
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
const code = nodeErrnoCode(e);
|
|
138
|
+
if (code !== 'EEXIST') {
|
|
139
|
+
throw e;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const holder = await readLockHolder(lockFilePath);
|
|
143
|
+
if (holder && isProcessAlive(holder.pid, { onProbeError: 'alive' })) {
|
|
144
|
+
return { status: 'busy', holder };
|
|
145
|
+
}
|
|
146
|
+
const stalePid = holder?.pid;
|
|
147
|
+
logger?.warn(`Removing stale ${spec.staleLogNoun} at "${lockFilePath}"` +
|
|
148
|
+
(stalePid !== undefined ? ` (pid ${stalePid} is not running)` : ''));
|
|
149
|
+
await fs.unlink(lockFilePath).catch(() => { });
|
|
150
|
+
return { status: 'stale_removed' };
|
|
151
|
+
}
|
|
152
|
+
async function acquireWorkspaceFileLock(input) {
|
|
153
|
+
const { spec, globalConfigFolderPath, workspacePath, lumpName, mode, projectName, logger } = input;
|
|
154
|
+
const normalizedWorkspacePath = path.resolve(workspacePath);
|
|
155
|
+
const locksDir = workspaceLocksDirPath({ globalConfigFolderPath, spec });
|
|
156
|
+
await fs.mkdir(locksDir, { recursive: true });
|
|
157
|
+
const lockFilePath = workspaceLockFilePath({
|
|
158
|
+
globalConfigFolderPath,
|
|
159
|
+
workspacePath: normalizedWorkspacePath,
|
|
160
|
+
spec,
|
|
161
|
+
});
|
|
162
|
+
const payload = {
|
|
163
|
+
pid: process.pid,
|
|
164
|
+
lumpName,
|
|
165
|
+
startedAt: new Date().toISOString(),
|
|
166
|
+
[spec.workspacePathField]: normalizedWorkspacePath,
|
|
167
|
+
...(projectName !== undefined ? { projectName } : {}),
|
|
168
|
+
};
|
|
169
|
+
let loggedWait = false;
|
|
170
|
+
for (;;) {
|
|
171
|
+
const attempt = await tryAcquireWorkspaceFileLockOnce({ lockFilePath, payload, spec, logger });
|
|
172
|
+
if (attempt.status === 'acquired') {
|
|
173
|
+
const release = async () => {
|
|
174
|
+
try {
|
|
175
|
+
const holder = await readLockHolder(lockFilePath);
|
|
176
|
+
if (holder?.pid === process.pid) {
|
|
177
|
+
await fs.unlink(lockFilePath);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// lock already gone
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
return success(release);
|
|
185
|
+
}
|
|
186
|
+
if (attempt.status === 'stale_removed') {
|
|
187
|
+
loggedWait = false;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (mode === 'fail') {
|
|
191
|
+
return failure({
|
|
192
|
+
code: spec.busyCode,
|
|
193
|
+
message: formatBusyMessage({
|
|
194
|
+
spec,
|
|
195
|
+
workspacePath: normalizedWorkspacePath,
|
|
196
|
+
holder: attempt.holder,
|
|
197
|
+
}),
|
|
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
|
+
});
|
|
204
|
+
}
|
|
205
|
+
if (!loggedWait) {
|
|
206
|
+
logger?.info(formatWorkspaceFileWaitMessage({
|
|
207
|
+
spec,
|
|
208
|
+
workspacePath: normalizedWorkspacePath,
|
|
209
|
+
holder: attempt.holder,
|
|
210
|
+
}));
|
|
211
|
+
loggedWait = true;
|
|
212
|
+
}
|
|
213
|
+
await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_MS));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Resolves the absolute git common directory for a worktree or main checkout
|
|
219
|
+
* (`git rev-parse --git-common-dir`).
|
|
220
|
+
*/
|
|
221
|
+
async function resolveGitCommonDir(input) {
|
|
222
|
+
const { cwd } = input;
|
|
223
|
+
const result = await execAsync('git rev-parse --path-format=absolute --git-common-dir', { cwd });
|
|
224
|
+
if (!result.success) {
|
|
225
|
+
// Older git without --path-format=absolute
|
|
226
|
+
const fallback = await execAsync('git rev-parse --git-common-dir', { cwd });
|
|
227
|
+
if (!fallback.success) {
|
|
228
|
+
return failure(`Failed to resolve git common dir: ${fallback.data.message}`);
|
|
229
|
+
}
|
|
230
|
+
const raw = fallback.data.stdout.trim();
|
|
231
|
+
return success(path.resolve(cwd, raw));
|
|
232
|
+
}
|
|
233
|
+
return success(path.resolve(result.data.stdout.trim()));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const GIT_COMMON_DIR_LOCK_SPEC = {
|
|
237
|
+
locksSubdirName: 'git-common-dir-locks',
|
|
238
|
+
busyCode: 'gitCommonDirBusy',
|
|
239
|
+
workspacePathField: 'gitCommonDir',
|
|
240
|
+
workspaceLabel: 'Git common dir',
|
|
241
|
+
waitLogNoun: 'git common dir',
|
|
242
|
+
staleLogNoun: 'git common dir lock',
|
|
243
|
+
};
|
|
244
|
+
async function acquireGitCommonDirLock(input) {
|
|
245
|
+
const commonDirResult = await resolveGitCommonDir({ cwd: input.gitCwd });
|
|
246
|
+
if (!commonDirResult.success) {
|
|
247
|
+
return commonDirResult;
|
|
248
|
+
}
|
|
249
|
+
return acquireWorkspaceFileLock({
|
|
250
|
+
spec: GIT_COMMON_DIR_LOCK_SPEC,
|
|
251
|
+
globalConfigFolderPath: input.globalConfigFolderPath,
|
|
252
|
+
workspacePath: commonDirResult.data,
|
|
253
|
+
lumpName: input.lumpName,
|
|
254
|
+
mode: input.lockMode,
|
|
255
|
+
projectName: input.projectName,
|
|
256
|
+
logger: input.logger,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
/** Acquire, run `fn`, always release. */
|
|
260
|
+
async function withGitCommonDirLock(input) {
|
|
261
|
+
const lockResult = await acquireGitCommonDirLock(input.lock);
|
|
262
|
+
if (!lockResult.success) {
|
|
263
|
+
return lockResult;
|
|
264
|
+
}
|
|
265
|
+
const releaseLock = lockResult.data;
|
|
266
|
+
try {
|
|
267
|
+
return success(await input.fn());
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
await releaseLock();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* One locked `git fetch --prune --no-write-fetch-head` for context-status refresh.
|
|
276
|
+
* Uses `projectRoot` from the refresh call as `gitCwd` (overrides lock default).
|
|
277
|
+
*/
|
|
278
|
+
function makeLockedRefreshRemoteTrackingRefsFn(input) {
|
|
279
|
+
const { gitLock } = input;
|
|
280
|
+
return async ({ projectRoot, remoteName }) => {
|
|
281
|
+
const locked = await withGitCommonDirLock({
|
|
282
|
+
lock: { ...gitLock, gitCwd: projectRoot },
|
|
283
|
+
fn: async () => refreshRemoteTrackingRefs({ projectRoot, remoteName }),
|
|
284
|
+
});
|
|
285
|
+
if (!locked.success) {
|
|
286
|
+
return failure(typeof locked.data === 'string' ? locked.data : locked.data.message);
|
|
287
|
+
}
|
|
288
|
+
return locked.data;
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function getContextStatuses(input) {
|
|
293
|
+
const { projectRoot, lumpName, baseBranch, contextNames, skipRefresh = false } = input;
|
|
294
|
+
const uniqueNames = [...new Set(contextNames)];
|
|
295
|
+
const gitCommitMessageFn = makeGitCommitMessageFnFromLumpName(lumpName);
|
|
296
|
+
if (!skipRefresh) {
|
|
297
|
+
const refreshRemoteTrackingRefsFn = makeLockedRefreshRemoteTrackingRefsFn({
|
|
298
|
+
gitLock: {
|
|
299
|
+
globalConfigFolderPath: globalConfigFolderPath,
|
|
300
|
+
gitCwd: projectRoot,
|
|
301
|
+
lumpName,
|
|
302
|
+
lockMode: 'wait',
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
const refreshResult = await refreshRemoteTrackingRefsFn({ projectRoot });
|
|
306
|
+
if (!refreshResult.success) {
|
|
307
|
+
return new Map(uniqueNames.map((name) => [name, 'toDo']));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const statuses = await Promise.all(uniqueNames.map((contextName) => getContextStatus$1({
|
|
35
311
|
contextName,
|
|
36
|
-
gitCommitMessageFn
|
|
312
|
+
gitCommitMessageFn,
|
|
37
313
|
projectRoot,
|
|
38
314
|
baseBranch,
|
|
315
|
+
skipFetch: true,
|
|
316
|
+
})));
|
|
317
|
+
return new Map(uniqueNames.map((name, i) => [name, statuses[i]]));
|
|
318
|
+
}
|
|
319
|
+
async function getContextStatus(input) {
|
|
320
|
+
const { contextName, ...rest } = input;
|
|
321
|
+
const statuses = await getContextStatuses({
|
|
322
|
+
...rest,
|
|
323
|
+
contextNames: [contextName],
|
|
39
324
|
});
|
|
325
|
+
return statuses.get(contextName) ?? 'toDo';
|
|
40
326
|
}
|
|
41
327
|
|
|
42
328
|
function parseYamlList(raw) {
|
|
@@ -80,4 +366,4 @@ function normalizePromptAndSteps({ prompt, jsSteps, }) {
|
|
|
80
366
|
return { prompt, jsSteps };
|
|
81
367
|
}
|
|
82
368
|
|
|
83
|
-
export { getContextStatus, getGitCommitMessage, getLumpCommitPrefixForLump, makeGitCommitMessageFnFromLumpName, normalizeSteps, readYamlList };
|
|
369
|
+
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
|
|
3
|
+
"version": "0.1.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.0
|
|
47
|
-
"@lumpcode/core": "^0.0
|
|
46
|
+
"@lumpcode/cli-types": "^0.1.0",
|
|
47
|
+
"@lumpcode/core": "^0.1.0",
|
|
48
48
|
"js-yaml": "^5.0.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|