@lumenflow/cli 1.3.5 → 1.3.6
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/__tests__/release.test.js +147 -1
- package/dist/release.js +112 -6
- package/dist/wu-release.js +142 -0
- package/package.json +7 -6
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* WU-1074: Add release command for npm publishing
|
|
13
13
|
*/
|
|
14
14
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
15
|
-
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
15
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
16
16
|
import { join } from 'node:path';
|
|
17
17
|
import { tmpdir } from 'node:os';
|
|
18
18
|
// Import functions under test
|
|
@@ -135,3 +135,149 @@ describe('release command integration', () => {
|
|
|
135
135
|
expect(typeof module.updatePackageVersions).toBe('function');
|
|
136
136
|
});
|
|
137
137
|
});
|
|
138
|
+
/**
|
|
139
|
+
* WU-1077: Tests for release script bug fixes
|
|
140
|
+
*
|
|
141
|
+
* Verifies:
|
|
142
|
+
* - hasNpmAuth() detects auth from ~/.npmrc not just env vars
|
|
143
|
+
* - Changeset pre mode is detected and exited in micro-worktree
|
|
144
|
+
* - Tag push bypasses pre-push hooks via LUMENFLOW_FORCE
|
|
145
|
+
*/
|
|
146
|
+
describe('WU-1077: release script bug fixes', () => {
|
|
147
|
+
describe('hasNpmAuth - ~/.npmrc detection', () => {
|
|
148
|
+
let testDir;
|
|
149
|
+
beforeEach(() => {
|
|
150
|
+
testDir = join(tmpdir(), `release-npmrc-test-${Date.now()}`);
|
|
151
|
+
mkdirSync(testDir, { recursive: true });
|
|
152
|
+
});
|
|
153
|
+
afterEach(() => {
|
|
154
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
155
|
+
});
|
|
156
|
+
it('should detect auth from ~/.npmrc authToken line', async () => {
|
|
157
|
+
// Import the function we're testing
|
|
158
|
+
const { hasNpmAuth } = await import('../release.js');
|
|
159
|
+
// Create a mock .npmrc with auth token
|
|
160
|
+
const npmrcPath = join(testDir, '.npmrc');
|
|
161
|
+
writeFileSync(npmrcPath, '//registry.npmjs.org/:_authToken=npm_testToken123\n');
|
|
162
|
+
// Test that it detects auth from the file
|
|
163
|
+
const result = hasNpmAuth(npmrcPath);
|
|
164
|
+
expect(result).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
it('should return false when ~/.npmrc has no auth token', async () => {
|
|
167
|
+
const { hasNpmAuth } = await import('../release.js');
|
|
168
|
+
// Create a mock .npmrc without auth token
|
|
169
|
+
const npmrcPath = join(testDir, '.npmrc');
|
|
170
|
+
writeFileSync(npmrcPath, 'registry=https://registry.npmjs.org\n');
|
|
171
|
+
const result = hasNpmAuth(npmrcPath);
|
|
172
|
+
expect(result).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
it('should return false when ~/.npmrc does not exist', async () => {
|
|
175
|
+
const { hasNpmAuth } = await import('../release.js');
|
|
176
|
+
// Non-existent path
|
|
177
|
+
const npmrcPath = join(testDir, 'nonexistent', '.npmrc');
|
|
178
|
+
const result = hasNpmAuth(npmrcPath);
|
|
179
|
+
expect(result).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
it('should still detect auth from NPM_TOKEN env var', async () => {
|
|
182
|
+
const { hasNpmAuth } = await import('../release.js');
|
|
183
|
+
// Set env var
|
|
184
|
+
const originalNpmToken = process.env.NPM_TOKEN;
|
|
185
|
+
process.env.NPM_TOKEN = 'test_token';
|
|
186
|
+
try {
|
|
187
|
+
// No npmrc file provided, should check env var
|
|
188
|
+
const result = hasNpmAuth();
|
|
189
|
+
expect(result).toBe(true);
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
// Restore
|
|
193
|
+
if (originalNpmToken === undefined) {
|
|
194
|
+
delete process.env.NPM_TOKEN;
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
process.env.NPM_TOKEN = originalNpmToken;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
describe('isInChangesetPreMode', () => {
|
|
203
|
+
let testDir;
|
|
204
|
+
beforeEach(() => {
|
|
205
|
+
testDir = join(tmpdir(), `release-pre-test-${Date.now()}`);
|
|
206
|
+
mkdirSync(testDir, { recursive: true });
|
|
207
|
+
});
|
|
208
|
+
afterEach(() => {
|
|
209
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
210
|
+
});
|
|
211
|
+
it('should return true when .changeset/pre.json exists', async () => {
|
|
212
|
+
const { isInChangesetPreMode } = await import('../release.js');
|
|
213
|
+
// Create .changeset directory and pre.json
|
|
214
|
+
const changesetDir = join(testDir, '.changeset');
|
|
215
|
+
mkdirSync(changesetDir, { recursive: true });
|
|
216
|
+
writeFileSync(join(changesetDir, 'pre.json'), JSON.stringify({
|
|
217
|
+
mode: 'pre',
|
|
218
|
+
tag: 'next',
|
|
219
|
+
initialVersions: {},
|
|
220
|
+
changesets: [],
|
|
221
|
+
}));
|
|
222
|
+
const result = isInChangesetPreMode(testDir);
|
|
223
|
+
expect(result).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
it('should return false when .changeset/pre.json does not exist', async () => {
|
|
226
|
+
const { isInChangesetPreMode } = await import('../release.js');
|
|
227
|
+
// Create .changeset directory without pre.json
|
|
228
|
+
const changesetDir = join(testDir, '.changeset');
|
|
229
|
+
mkdirSync(changesetDir, { recursive: true });
|
|
230
|
+
writeFileSync(join(changesetDir, 'config.json'), JSON.stringify({ access: 'public' }));
|
|
231
|
+
const result = isInChangesetPreMode(testDir);
|
|
232
|
+
expect(result).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
it('should return false when .changeset directory does not exist', async () => {
|
|
235
|
+
const { isInChangesetPreMode } = await import('../release.js');
|
|
236
|
+
const result = isInChangesetPreMode(testDir);
|
|
237
|
+
expect(result).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
describe('exitChangesetPreMode', () => {
|
|
241
|
+
let testDir;
|
|
242
|
+
beforeEach(() => {
|
|
243
|
+
testDir = join(tmpdir(), `release-exit-pre-test-${Date.now()}`);
|
|
244
|
+
mkdirSync(testDir, { recursive: true });
|
|
245
|
+
});
|
|
246
|
+
afterEach(() => {
|
|
247
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
248
|
+
});
|
|
249
|
+
it('should delete .changeset/pre.json to exit pre mode', async () => {
|
|
250
|
+
const { exitChangesetPreMode, isInChangesetPreMode } = await import('../release.js');
|
|
251
|
+
// Create .changeset directory and pre.json
|
|
252
|
+
const changesetDir = join(testDir, '.changeset');
|
|
253
|
+
mkdirSync(changesetDir, { recursive: true });
|
|
254
|
+
const preJsonPath = join(changesetDir, 'pre.json');
|
|
255
|
+
writeFileSync(preJsonPath, JSON.stringify({
|
|
256
|
+
mode: 'pre',
|
|
257
|
+
tag: 'next',
|
|
258
|
+
initialVersions: {},
|
|
259
|
+
changesets: [],
|
|
260
|
+
}));
|
|
261
|
+
// Verify pre mode is active
|
|
262
|
+
expect(isInChangesetPreMode(testDir)).toBe(true);
|
|
263
|
+
// Exit pre mode
|
|
264
|
+
exitChangesetPreMode(testDir);
|
|
265
|
+
// Verify pre mode is no longer active
|
|
266
|
+
expect(isInChangesetPreMode(testDir)).toBe(false);
|
|
267
|
+
expect(existsSync(preJsonPath)).toBe(false);
|
|
268
|
+
});
|
|
269
|
+
it('should not throw when .changeset/pre.json does not exist', async () => {
|
|
270
|
+
const { exitChangesetPreMode } = await import('../release.js');
|
|
271
|
+
// No pre.json file exists
|
|
272
|
+
expect(() => exitChangesetPreMode(testDir)).not.toThrow();
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
describe('pushTagWithForce', () => {
|
|
276
|
+
it('should export pushTagWithForce function', async () => {
|
|
277
|
+
const { pushTagWithForce } = await import('../release.js');
|
|
278
|
+
expect(typeof pushTagWithForce).toBe('function');
|
|
279
|
+
});
|
|
280
|
+
// Integration test would require git setup - functional verification
|
|
281
|
+
// is done by checking the function uses LUMENFLOW_FORCE env var
|
|
282
|
+
});
|
|
283
|
+
});
|
package/dist/release.js
CHANGED
|
@@ -20,9 +20,10 @@
|
|
|
20
20
|
* WU-1074: Add release command for npm publishing
|
|
21
21
|
*/
|
|
22
22
|
import { Command } from 'commander';
|
|
23
|
-
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
23
|
+
import { existsSync, readFileSync, readdirSync, statSync, unlinkSync, } from 'node:fs';
|
|
24
24
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
25
25
|
import { join } from 'node:path';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
26
27
|
import { execSync } from 'node:child_process';
|
|
27
28
|
import { getGitForCwd } from '@lumenflow/core/dist/git-adapter.js';
|
|
28
29
|
import { die } from '@lumenflow/core/dist/error-handler.js';
|
|
@@ -46,6 +47,16 @@ const NPM_REGISTRY = 'https://registry.npmjs.org';
|
|
|
46
47
|
const NPM_TOKEN_ENV = 'NPM_TOKEN';
|
|
47
48
|
/** Environment variable for alternative npm auth */
|
|
48
49
|
const NODE_AUTH_TOKEN_ENV = 'NODE_AUTH_TOKEN';
|
|
50
|
+
/** Pattern to detect npm auth token in .npmrc files */
|
|
51
|
+
const NPMRC_AUTH_TOKEN_PATTERN = /_authToken=/;
|
|
52
|
+
/** Changeset pre.json filename */
|
|
53
|
+
const CHANGESET_PRE_JSON = 'pre.json';
|
|
54
|
+
/** Changeset directory name */
|
|
55
|
+
const CHANGESET_DIR = '.changeset';
|
|
56
|
+
/** Environment variable to force bypass hooks */
|
|
57
|
+
const LUMENFLOW_FORCE_ENV = 'LUMENFLOW_FORCE';
|
|
58
|
+
/** Environment variable to provide reason for force bypass */
|
|
59
|
+
const LUMENFLOW_FORCE_REASON_ENV = 'LUMENFLOW_FORCE_REASON';
|
|
49
60
|
/**
|
|
50
61
|
* Validate that a string is a valid semver version
|
|
51
62
|
*
|
|
@@ -160,10 +171,94 @@ function runCommand(cmd, options = {}) {
|
|
|
160
171
|
/**
|
|
161
172
|
* Check if npm authentication is available
|
|
162
173
|
*
|
|
163
|
-
*
|
|
174
|
+
* Checks for auth in this order:
|
|
175
|
+
* 1. NPM_TOKEN environment variable
|
|
176
|
+
* 2. NODE_AUTH_TOKEN environment variable
|
|
177
|
+
* 3. Auth token in specified .npmrc file (or ~/.npmrc by default)
|
|
178
|
+
*
|
|
179
|
+
* @param npmrcPath - Optional path to .npmrc file (defaults to ~/.npmrc)
|
|
180
|
+
* @returns true if any auth method is found
|
|
181
|
+
*/
|
|
182
|
+
export function hasNpmAuth(npmrcPath) {
|
|
183
|
+
// Check environment variables first
|
|
184
|
+
if (process.env[NPM_TOKEN_ENV] || process.env[NODE_AUTH_TOKEN_ENV]) {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
// Check .npmrc file
|
|
188
|
+
const npmrcFile = npmrcPath ?? join(homedir(), '.npmrc');
|
|
189
|
+
if (existsSync(npmrcFile)) {
|
|
190
|
+
try {
|
|
191
|
+
const content = readFileSync(npmrcFile, { encoding: FILE_SYSTEM.UTF8 });
|
|
192
|
+
// Look for authToken lines (e.g., //registry.npmjs.org/:_authToken=...)
|
|
193
|
+
return NPMRC_AUTH_TOKEN_PATTERN.test(content);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// If we can't read the file, assume no auth
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Check if the project is in changeset pre-release mode
|
|
204
|
+
*
|
|
205
|
+
* Changeset pre mode is indicated by the presence of .changeset/pre.json
|
|
206
|
+
*
|
|
207
|
+
* @param baseDir - Base directory to check (defaults to cwd)
|
|
208
|
+
* @returns true if in pre-release mode
|
|
209
|
+
*/
|
|
210
|
+
export function isInChangesetPreMode(baseDir = process.cwd()) {
|
|
211
|
+
const preJsonPath = join(baseDir, CHANGESET_DIR, CHANGESET_PRE_JSON);
|
|
212
|
+
return existsSync(preJsonPath);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Exit changeset pre-release mode by removing .changeset/pre.json
|
|
216
|
+
*
|
|
217
|
+
* This is safe to call even if not in pre mode (no-op if file doesn't exist)
|
|
218
|
+
*
|
|
219
|
+
* @param baseDir - Base directory to operate in (defaults to cwd)
|
|
220
|
+
*/
|
|
221
|
+
export function exitChangesetPreMode(baseDir = process.cwd()) {
|
|
222
|
+
const preJsonPath = join(baseDir, CHANGESET_DIR, CHANGESET_PRE_JSON);
|
|
223
|
+
if (existsSync(preJsonPath)) {
|
|
224
|
+
unlinkSync(preJsonPath);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Push a git tag to origin, bypassing pre-push hooks via LUMENFLOW_FORCE
|
|
229
|
+
*
|
|
230
|
+
* This is necessary because the release script runs in a micro-worktree context
|
|
231
|
+
* and pre-push hooks may block tag pushes. The force is logged and requires
|
|
232
|
+
* a reason for audit purposes.
|
|
233
|
+
*
|
|
234
|
+
* @param git - SimpleGit instance
|
|
235
|
+
* @param tagName - Name of the tag to push
|
|
236
|
+
* @param reason - Reason for bypassing hooks (for audit log)
|
|
164
237
|
*/
|
|
165
|
-
function
|
|
166
|
-
|
|
238
|
+
export async function pushTagWithForce(git, tagName, reason = 'release: tag push from micro-worktree') {
|
|
239
|
+
// Set environment variables to bypass hooks
|
|
240
|
+
const originalForce = process.env[LUMENFLOW_FORCE_ENV];
|
|
241
|
+
const originalReason = process.env[LUMENFLOW_FORCE_REASON_ENV];
|
|
242
|
+
try {
|
|
243
|
+
process.env[LUMENFLOW_FORCE_ENV] = '1';
|
|
244
|
+
process.env[LUMENFLOW_FORCE_REASON_ENV] = reason;
|
|
245
|
+
await git.push(REMOTES.ORIGIN, tagName);
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
// Restore original environment
|
|
249
|
+
if (originalForce === undefined) {
|
|
250
|
+
delete process.env[LUMENFLOW_FORCE_ENV];
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
process.env[LUMENFLOW_FORCE_ENV] = originalForce;
|
|
254
|
+
}
|
|
255
|
+
if (originalReason === undefined) {
|
|
256
|
+
delete process.env[LUMENFLOW_FORCE_REASON_ENV];
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
process.env[LUMENFLOW_FORCE_REASON_ENV] = originalReason;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
167
262
|
}
|
|
168
263
|
/**
|
|
169
264
|
* Main release function
|
|
@@ -231,6 +326,12 @@ async function main() {
|
|
|
231
326
|
id: `v${version}`,
|
|
232
327
|
logPrefix: LOG_PREFIX,
|
|
233
328
|
execute: async ({ worktreePath }) => {
|
|
329
|
+
// Check and exit changeset pre mode if active
|
|
330
|
+
if (isInChangesetPreMode(worktreePath)) {
|
|
331
|
+
console.log(`${LOG_PREFIX} Detected changeset pre-release mode, exiting...`);
|
|
332
|
+
exitChangesetPreMode(worktreePath);
|
|
333
|
+
console.log(`${LOG_PREFIX} ✅ Exited changeset pre mode`);
|
|
334
|
+
}
|
|
234
335
|
// Find package paths within the worktree
|
|
235
336
|
const worktreePackagePaths = findPackageJsonPaths(worktreePath);
|
|
236
337
|
// Update versions
|
|
@@ -238,10 +339,15 @@ async function main() {
|
|
|
238
339
|
await updatePackageVersions(worktreePackagePaths, version);
|
|
239
340
|
// Get relative paths for commit
|
|
240
341
|
const relativePaths = worktreePackagePaths.map((p) => getRelativePath(p, worktreePath));
|
|
342
|
+
// If we exited pre mode, include the deleted pre.json in files to commit
|
|
343
|
+
// (the deletion will be staged automatically by git add -A behavior)
|
|
344
|
+
const changesetPrePath = join(CHANGESET_DIR, CHANGESET_PRE_JSON);
|
|
345
|
+
const filesToCommit = [...relativePaths];
|
|
346
|
+
// Note: Deletion of pre.json is handled by git detecting the missing file
|
|
241
347
|
console.log(`${LOG_PREFIX} ✅ Versions updated to ${version}`);
|
|
242
348
|
return {
|
|
243
349
|
commitMessage: buildCommitMessage(version),
|
|
244
|
-
files:
|
|
350
|
+
files: filesToCommit,
|
|
245
351
|
};
|
|
246
352
|
},
|
|
247
353
|
});
|
|
@@ -266,7 +372,7 @@ async function main() {
|
|
|
266
372
|
await git.raw(['tag', '-a', tagName, '-m', `Release ${tagName}`]);
|
|
267
373
|
console.log(`${LOG_PREFIX} ✅ Tag created: ${tagName}`);
|
|
268
374
|
console.log(`${LOG_PREFIX} Pushing tag to ${REMOTES.ORIGIN}...`);
|
|
269
|
-
await git
|
|
375
|
+
await pushTagWithForce(git, tagName, 'release: pushing version tag');
|
|
270
376
|
console.log(`${LOG_PREFIX} ✅ Tag pushed`);
|
|
271
377
|
}
|
|
272
378
|
// Publish to npm
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* WU Release Command (WU-1080)
|
|
4
|
+
*
|
|
5
|
+
* Releases an orphaned WU from in_progress back to ready state.
|
|
6
|
+
* Use when an agent is interrupted mid-WU and the WU needs to be reclaimed.
|
|
7
|
+
*
|
|
8
|
+
* Sequence (micro-worktree pattern):
|
|
9
|
+
* 1) Validate WU is in_progress
|
|
10
|
+
* 2) Create micro-worktree from main
|
|
11
|
+
* 3) Append release event to state store
|
|
12
|
+
* 4) Regenerate backlog.md and status.md
|
|
13
|
+
* 5) Commit in micro-worktree, push directly to origin/main
|
|
14
|
+
* 6) Optionally remove the work worktree
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* pnpm wu:release --id WU-1080 --reason "Agent interrupted"
|
|
18
|
+
*/
|
|
19
|
+
import { writeFileSync } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { getGitForCwd } from '@lumenflow/core/dist/git-adapter.js';
|
|
22
|
+
import { die } from '@lumenflow/core/dist/error-handler.js';
|
|
23
|
+
import { generateBacklog, generateStatus } from '@lumenflow/core/dist/backlog-generator.js';
|
|
24
|
+
import { todayISO } from '@lumenflow/core/dist/date-utils.js';
|
|
25
|
+
import { createWUParser, WU_OPTIONS } from '@lumenflow/core/dist/arg-parser.js';
|
|
26
|
+
import { WU_PATHS } from '@lumenflow/core/dist/wu-paths.js';
|
|
27
|
+
import { readWU, writeWU, appendNote } from '@lumenflow/core/dist/wu-yaml.js';
|
|
28
|
+
import { REMOTES, BRANCHES, WU_STATUS, PATTERNS, FILE_SYSTEM, EXIT_CODES, MICRO_WORKTREE_OPERATIONS, } from '@lumenflow/core/dist/wu-constants.js';
|
|
29
|
+
import { ensureOnMain } from '@lumenflow/core/dist/wu-helpers.js';
|
|
30
|
+
import { withMicroWorktree } from '@lumenflow/core/dist/micro-worktree.js';
|
|
31
|
+
import { WUStateStore } from '@lumenflow/core/dist/wu-state-store.js';
|
|
32
|
+
import { releaseLaneLock } from '@lumenflow/core/dist/lane-lock.js';
|
|
33
|
+
const PREFIX = '[wu-release]';
|
|
34
|
+
async function main() {
|
|
35
|
+
const args = createWUParser({
|
|
36
|
+
name: 'wu-release',
|
|
37
|
+
description: 'Release an orphaned WU from in_progress back to ready state for reclaiming',
|
|
38
|
+
options: [WU_OPTIONS.id, WU_OPTIONS.reason],
|
|
39
|
+
required: ['id', 'reason'],
|
|
40
|
+
allowPositionalId: true,
|
|
41
|
+
});
|
|
42
|
+
const id = args.id.toUpperCase();
|
|
43
|
+
if (!PATTERNS.WU_ID.test(id))
|
|
44
|
+
die(`Invalid WU id '${args.id}'. Expected format WU-123`);
|
|
45
|
+
if (!args.reason) {
|
|
46
|
+
die('Reason is required for releasing a WU. Use --reason "..."');
|
|
47
|
+
}
|
|
48
|
+
await ensureOnMain(getGitForCwd());
|
|
49
|
+
// Read WU doc from main to validate state
|
|
50
|
+
const mainWUPath = WU_PATHS.WU(id);
|
|
51
|
+
let doc;
|
|
52
|
+
try {
|
|
53
|
+
doc = readWU(mainWUPath, id);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
die(`Failed to read WU ${id}: ${error.message}\n\n` +
|
|
57
|
+
`Options:\n` +
|
|
58
|
+
` 1. Check if WU file exists: ls -la ${mainWUPath}\n` +
|
|
59
|
+
` 2. Validate YAML syntax: pnpm wu:validate --id ${id}\n` +
|
|
60
|
+
` 3. Create WU if missing: pnpm wu:create --id ${id} --lane "<lane>" --title "..."`);
|
|
61
|
+
}
|
|
62
|
+
const title = doc.title || '';
|
|
63
|
+
const lane = doc.lane || 'Unknown';
|
|
64
|
+
// Validate current status is in_progress
|
|
65
|
+
const currentStatus = doc.status || WU_STATUS.READY;
|
|
66
|
+
if (currentStatus !== WU_STATUS.IN_PROGRESS) {
|
|
67
|
+
die(`Cannot release WU ${id}: current status is '${currentStatus}', expected 'in_progress'.\n\n` +
|
|
68
|
+
`The wu:release command is only for releasing orphaned WUs that are stuck in in_progress state.\n\n` +
|
|
69
|
+
`Current state transitions:\n` +
|
|
70
|
+
` - If status is 'ready': WU has not been claimed yet\n` +
|
|
71
|
+
` - If status is 'blocked': Use wu:unblock to resume work\n` +
|
|
72
|
+
` - If status is 'done': WU is already complete`);
|
|
73
|
+
}
|
|
74
|
+
const baseMsg = `wu(${id.toLowerCase()}): release`;
|
|
75
|
+
const commitMsg = `${baseMsg} — ${args.reason}`;
|
|
76
|
+
// Use micro-worktree pattern to avoid pre-commit hook blocking commits to main
|
|
77
|
+
await withMicroWorktree({
|
|
78
|
+
operation: MICRO_WORKTREE_OPERATIONS.WU_BLOCK, // Reuse block operation type
|
|
79
|
+
id,
|
|
80
|
+
logPrefix: PREFIX,
|
|
81
|
+
pushOnly: true, // Push directly to origin/main without touching local main
|
|
82
|
+
execute: async ({ worktreePath }) => {
|
|
83
|
+
// Build paths relative to micro-worktree
|
|
84
|
+
const microWUPath = path.join(worktreePath, WU_PATHS.WU(id));
|
|
85
|
+
const microStatusPath = path.join(worktreePath, WU_PATHS.STATUS());
|
|
86
|
+
const microBacklogPath = path.join(worktreePath, WU_PATHS.BACKLOG());
|
|
87
|
+
// Update WU YAML in micro-worktree - set status back to ready
|
|
88
|
+
const microDoc = readWU(microWUPath, id);
|
|
89
|
+
microDoc.status = WU_STATUS.READY;
|
|
90
|
+
const noteLine = `Released (${todayISO()}): ${args.reason}`;
|
|
91
|
+
appendNote(microDoc, noteLine);
|
|
92
|
+
writeWU(microWUPath, microDoc);
|
|
93
|
+
// Append release event to WUStateStore
|
|
94
|
+
const stateDir = path.join(worktreePath, '.lumenflow', 'state');
|
|
95
|
+
const store = new WUStateStore(stateDir);
|
|
96
|
+
await store.load();
|
|
97
|
+
await store.release(id, args.reason);
|
|
98
|
+
// Generate backlog.md and status.md from state store
|
|
99
|
+
const backlogContent = await generateBacklog(store);
|
|
100
|
+
writeFileSync(microBacklogPath, backlogContent, {
|
|
101
|
+
encoding: FILE_SYSTEM.UTF8,
|
|
102
|
+
});
|
|
103
|
+
const statusContent = await generateStatus(store);
|
|
104
|
+
writeFileSync(microStatusPath, statusContent, {
|
|
105
|
+
encoding: FILE_SYSTEM.UTF8,
|
|
106
|
+
});
|
|
107
|
+
return {
|
|
108
|
+
commitMessage: commitMsg,
|
|
109
|
+
files: [
|
|
110
|
+
WU_PATHS.WU(id),
|
|
111
|
+
WU_PATHS.STATUS(),
|
|
112
|
+
WU_PATHS.BACKLOG(),
|
|
113
|
+
'.lumenflow/state/wu-events.jsonl',
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
// Fetch to update local main tracking
|
|
119
|
+
await getGitForCwd().fetch(REMOTES.ORIGIN, BRANCHES.MAIN);
|
|
120
|
+
// Release lane lock so another WU can be claimed
|
|
121
|
+
try {
|
|
122
|
+
if (lane) {
|
|
123
|
+
const releaseResult = releaseLaneLock(lane, { wuId: id });
|
|
124
|
+
if (releaseResult.released && !releaseResult.notFound) {
|
|
125
|
+
console.log(`${PREFIX} Lane lock released for "${lane}"`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
// Non-blocking: lock release failure should not block the release operation
|
|
131
|
+
console.warn(`${PREFIX} Warning: Could not release lane lock: ${err.message}`);
|
|
132
|
+
}
|
|
133
|
+
console.log(`\n${PREFIX} WU released and pushed.`);
|
|
134
|
+
console.log(`- WU: ${id} — ${title}`);
|
|
135
|
+
console.log(`- Status: in_progress → ready`);
|
|
136
|
+
console.log(`- Reason: ${args.reason}`);
|
|
137
|
+
console.log(`\n${PREFIX} The WU can now be reclaimed with: pnpm wu:claim --id ${id} --lane "${lane}"`);
|
|
138
|
+
}
|
|
139
|
+
main().catch((e) => {
|
|
140
|
+
console.error(e.message);
|
|
141
|
+
process.exit(EXIT_CODES.ERROR);
|
|
142
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lumenflow/cli",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.6",
|
|
4
4
|
"description": "Command-line interface for LumenFlow workflow framework",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"lumenflow",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"wu-infer-lane": "./dist/wu-infer-lane.js",
|
|
39
39
|
"wu-delete": "./dist/wu-delete.js",
|
|
40
40
|
"wu-unlock-lane": "./dist/wu-unlock-lane.js",
|
|
41
|
+
"wu-release": "./dist/wu-release.js",
|
|
41
42
|
"mem-init": "./dist/mem-init.js",
|
|
42
43
|
"mem-checkpoint": "./dist/mem-checkpoint.js",
|
|
43
44
|
"mem-start": "./dist/mem-start.js",
|
|
@@ -86,11 +87,11 @@
|
|
|
86
87
|
"pretty-ms": "^9.2.0",
|
|
87
88
|
"simple-git": "^3.30.0",
|
|
88
89
|
"yaml": "^2.8.2",
|
|
89
|
-
"@lumenflow/core": "1.3.
|
|
90
|
-
"@lumenflow/metrics": "1.3.
|
|
91
|
-
"@lumenflow/
|
|
92
|
-
"@lumenflow/
|
|
93
|
-
"@lumenflow/
|
|
90
|
+
"@lumenflow/core": "1.3.6",
|
|
91
|
+
"@lumenflow/metrics": "1.3.6",
|
|
92
|
+
"@lumenflow/memory": "1.3.6",
|
|
93
|
+
"@lumenflow/initiatives": "1.3.6",
|
|
94
|
+
"@lumenflow/agent": "1.3.6"
|
|
94
95
|
},
|
|
95
96
|
"devDependencies": {
|
|
96
97
|
"@vitest/coverage-v8": "^4.0.17",
|