@lumenflow/cli 3.1.3 → 3.2.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.
Files changed (267) hide show
  1. package/dist/agent-issues-query.js.map +1 -0
  2. package/dist/agent-log-issue.js.map +1 -0
  3. package/dist/agent-session-end.js.map +1 -0
  4. package/dist/agent-session.js.map +1 -0
  5. package/dist/backlog-prune.js.map +1 -0
  6. package/dist/cli-entry-point.js +139 -0
  7. package/dist/cli-entry-point.js.map +1 -0
  8. package/dist/commands/integrate.js.map +1 -0
  9. package/dist/commands.js.map +1 -0
  10. package/dist/config-get.js.map +1 -0
  11. package/dist/config-set.js.map +1 -0
  12. package/dist/constants.js +98 -0
  13. package/dist/constants.js.map +1 -0
  14. package/dist/delegation-list.js.map +1 -0
  15. package/dist/deps-add.js +259 -0
  16. package/dist/deps-add.js.map +1 -0
  17. package/dist/deps-remove.js +105 -0
  18. package/dist/deps-remove.js.map +1 -0
  19. package/dist/docs-sync.js.map +1 -0
  20. package/dist/doctor.js.map +1 -0
  21. package/dist/file-delete.js.map +1 -0
  22. package/dist/file-edit.js.map +1 -0
  23. package/dist/file-read.js.map +1 -0
  24. package/dist/file-write.js.map +1 -0
  25. package/dist/flow-bottlenecks.js.map +1 -0
  26. package/dist/flow-report.js.map +1 -0
  27. package/dist/formatters.js +151 -0
  28. package/dist/formatters.js.map +1 -0
  29. package/dist/gate-defaults.js +137 -0
  30. package/dist/gate-defaults.js.map +1 -0
  31. package/dist/gate-registry.js +73 -0
  32. package/dist/gate-registry.js.map +1 -0
  33. package/dist/gates-graceful-degradation.js +153 -0
  34. package/dist/gates-graceful-degradation.js.map +1 -0
  35. package/dist/gates-plan-resolvers.js +190 -0
  36. package/dist/gates-plan-resolvers.js.map +1 -0
  37. package/dist/gates-runners.js +545 -0
  38. package/dist/gates-runners.js.map +1 -0
  39. package/dist/gates-types.js +4 -0
  40. package/dist/gates-types.js.map +1 -0
  41. package/dist/gates-utils.js +333 -0
  42. package/dist/gates-utils.js.map +1 -0
  43. package/dist/gates.js.map +1 -0
  44. package/dist/git-branch.js.map +1 -0
  45. package/dist/git-diff.js.map +1 -0
  46. package/dist/git-log.js.map +1 -0
  47. package/dist/git-status.js.map +1 -0
  48. package/dist/guard-locked.js +172 -0
  49. package/dist/guard-locked.js.map +1 -0
  50. package/dist/guard-main-branch.js +217 -0
  51. package/dist/guard-main-branch.js.map +1 -0
  52. package/dist/guard-worktree-commit.js +163 -0
  53. package/dist/guard-worktree-commit.js.map +1 -0
  54. package/dist/hooks/auto-checkpoint-utils.js +54 -0
  55. package/dist/hooks/auto-checkpoint-utils.js.map +1 -0
  56. package/dist/hooks/enforcement-checks.js +399 -0
  57. package/dist/hooks/enforcement-checks.js.map +1 -0
  58. package/dist/hooks/enforcement-generator.js +139 -0
  59. package/dist/hooks/enforcement-generator.js.map +1 -0
  60. package/dist/hooks/enforcement-sync.js +380 -0
  61. package/dist/hooks/enforcement-sync.js.map +1 -0
  62. package/dist/hooks/generators/auto-checkpoint.js +125 -0
  63. package/dist/hooks/generators/auto-checkpoint.js.map +1 -0
  64. package/dist/hooks/generators/enforce-worktree.js +190 -0
  65. package/dist/hooks/generators/enforce-worktree.js.map +1 -0
  66. package/dist/hooks/generators/index.js +18 -0
  67. package/dist/hooks/generators/index.js.map +1 -0
  68. package/dist/hooks/generators/pre-compact-checkpoint.js +136 -0
  69. package/dist/hooks/generators/pre-compact-checkpoint.js.map +1 -0
  70. package/dist/hooks/generators/require-wu.js +117 -0
  71. package/dist/hooks/generators/require-wu.js.map +1 -0
  72. package/dist/hooks/generators/session-start-recovery.js +103 -0
  73. package/dist/hooks/generators/session-start-recovery.js.map +1 -0
  74. package/dist/hooks/generators/signal-utils.js +54 -0
  75. package/dist/hooks/generators/signal-utils.js.map +1 -0
  76. package/dist/hooks/generators/warn-incomplete.js +67 -0
  77. package/dist/hooks/generators/warn-incomplete.js.map +1 -0
  78. package/dist/hooks/index.js +10 -0
  79. package/dist/hooks/index.js.map +1 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/init-detection.js +232 -0
  82. package/dist/init-detection.js.map +1 -0
  83. package/dist/init-lane-validation.js +147 -0
  84. package/dist/init-lane-validation.js.map +1 -0
  85. package/dist/init-scaffolding.js +158 -0
  86. package/dist/init-scaffolding.js.map +1 -0
  87. package/dist/init-templates.js +1983 -0
  88. package/dist/init-templates.js.map +1 -0
  89. package/dist/init.js.map +1 -0
  90. package/dist/initiative-add-wu.js.map +1 -0
  91. package/dist/initiative-bulk-assign-wus.js.map +1 -0
  92. package/dist/initiative-create.js.map +1 -0
  93. package/dist/initiative-edit.js.map +1 -0
  94. package/dist/initiative-list.js.map +1 -0
  95. package/dist/initiative-plan.js.map +1 -0
  96. package/dist/initiative-remove-wu.js.map +1 -0
  97. package/dist/initiative-status.js.map +1 -0
  98. package/dist/lane-edit.js.map +1 -0
  99. package/dist/lane-health.js.map +1 -0
  100. package/dist/lane-lifecycle-process.js +381 -0
  101. package/dist/lane-lifecycle-process.js.map +1 -0
  102. package/dist/lane-lock.js.map +1 -0
  103. package/dist/lane-setup.js.map +1 -0
  104. package/dist/lane-status.js.map +1 -0
  105. package/dist/lane-suggest.js.map +1 -0
  106. package/dist/lane-validate.js.map +1 -0
  107. package/dist/lifecycle-regression-harness.js +181 -0
  108. package/dist/lifecycle-regression-harness.js.map +1 -0
  109. package/dist/lumenflow-upgrade.js +18 -10
  110. package/dist/lumenflow-upgrade.js.map +1 -0
  111. package/dist/mem-checkpoint.js.map +1 -0
  112. package/dist/mem-cleanup.js.map +1 -0
  113. package/dist/mem-context.js.map +1 -0
  114. package/dist/mem-create.js.map +1 -0
  115. package/dist/mem-delete.js.map +1 -0
  116. package/dist/mem-export.js.map +1 -0
  117. package/dist/mem-inbox.js.map +1 -0
  118. package/dist/mem-index.js +214 -0
  119. package/dist/mem-index.js.map +1 -0
  120. package/dist/mem-init.js.map +1 -0
  121. package/dist/mem-profile.js +210 -0
  122. package/dist/mem-profile.js.map +1 -0
  123. package/dist/mem-promote.js +257 -0
  124. package/dist/mem-promote.js.map +1 -0
  125. package/dist/mem-ready.js.map +1 -0
  126. package/dist/mem-recover.js.map +1 -0
  127. package/dist/mem-signal.js.map +1 -0
  128. package/dist/mem-start.js.map +1 -0
  129. package/dist/mem-summarize.js.map +1 -0
  130. package/dist/mem-triage.js.map +1 -0
  131. package/dist/merge-block.js +225 -0
  132. package/dist/merge-block.js.map +1 -0
  133. package/dist/metrics-cli.js.map +1 -0
  134. package/dist/metrics-snapshot.js.map +1 -0
  135. package/dist/object-guards.js +9 -0
  136. package/dist/object-guards.js.map +1 -0
  137. package/dist/onboard.js.map +1 -0
  138. package/dist/onboarding-smoke-test.js +432 -0
  139. package/dist/onboarding-smoke-test.js.map +1 -0
  140. package/dist/orchestrate-init-status.js.map +1 -0
  141. package/dist/orchestrate-initiative.js.map +1 -0
  142. package/dist/orchestrate-monitor.js.map +1 -0
  143. package/dist/pack-author.js.map +1 -0
  144. package/dist/pack-hash.js.map +1 -0
  145. package/dist/pack-install.js.map +1 -0
  146. package/dist/pack-publish.js.map +1 -0
  147. package/dist/pack-scaffold.js.map +1 -0
  148. package/dist/pack-search.js.map +1 -0
  149. package/dist/pack-validate.js.map +1 -0
  150. package/dist/plan-create.js.map +1 -0
  151. package/dist/plan-edit.js.map +1 -0
  152. package/dist/plan-link.js.map +1 -0
  153. package/dist/plan-promote.js.map +1 -0
  154. package/dist/public-manifest.js +931 -0
  155. package/dist/public-manifest.js.map +1 -0
  156. package/dist/release.js +664 -116
  157. package/dist/release.js.map +1 -0
  158. package/dist/rotate-progress.js +253 -0
  159. package/dist/rotate-progress.js.map +1 -0
  160. package/dist/session-coordinator.js +303 -0
  161. package/dist/session-coordinator.js.map +1 -0
  162. package/dist/shared-validators.js +81 -0
  163. package/dist/shared-validators.js.map +1 -0
  164. package/dist/signal-cleanup.js.map +1 -0
  165. package/dist/state-bootstrap.js.map +1 -0
  166. package/dist/state-cleanup.js.map +1 -0
  167. package/dist/state-doctor-fix.js +226 -0
  168. package/dist/state-doctor-fix.js.map +1 -0
  169. package/dist/state-doctor-stamps.js +23 -0
  170. package/dist/state-doctor-stamps.js.map +1 -0
  171. package/dist/state-doctor.js.map +1 -0
  172. package/dist/strict-progress.js +255 -0
  173. package/dist/strict-progress.js.map +1 -0
  174. package/dist/sync-templates.js.map +1 -0
  175. package/dist/task-claim.js.map +1 -0
  176. package/dist/trace-gen.js +401 -0
  177. package/dist/trace-gen.js.map +1 -0
  178. package/dist/validate-agent-skills.js +224 -0
  179. package/dist/validate-agent-skills.js.map +1 -0
  180. package/dist/validate-agent-sync.js +152 -0
  181. package/dist/validate-agent-sync.js.map +1 -0
  182. package/dist/validate-backlog-sync.js +77 -0
  183. package/dist/validate-backlog-sync.js.map +1 -0
  184. package/dist/validate-skills-spec.js +211 -0
  185. package/dist/validate-skills-spec.js.map +1 -0
  186. package/dist/validate.js.map +1 -0
  187. package/dist/validator-defaults.js +107 -0
  188. package/dist/validator-defaults.js.map +1 -0
  189. package/dist/validator-registry.js +71 -0
  190. package/dist/validator-registry.js.map +1 -0
  191. package/dist/workspace-init.js.map +1 -0
  192. package/dist/wu-block.js.map +1 -0
  193. package/dist/wu-brief.js.map +1 -0
  194. package/dist/wu-claim-branch.js +123 -0
  195. package/dist/wu-claim-branch.js.map +1 -0
  196. package/dist/wu-claim-cloud.js +79 -0
  197. package/dist/wu-claim-cloud.js.map +1 -0
  198. package/dist/wu-claim-mode.js +82 -0
  199. package/dist/wu-claim-mode.js.map +1 -0
  200. package/dist/wu-claim-output.js +85 -0
  201. package/dist/wu-claim-output.js.map +1 -0
  202. package/dist/wu-claim-repair-guidance.js +12 -0
  203. package/dist/wu-claim-repair-guidance.js.map +1 -0
  204. package/dist/wu-claim-resume-handler.js +87 -0
  205. package/dist/wu-claim-resume-handler.js.map +1 -0
  206. package/dist/wu-claim-state.js +581 -0
  207. package/dist/wu-claim-state.js.map +1 -0
  208. package/dist/wu-claim-validation.js +458 -0
  209. package/dist/wu-claim-validation.js.map +1 -0
  210. package/dist/wu-claim-worktree.js +238 -0
  211. package/dist/wu-claim-worktree.js.map +1 -0
  212. package/dist/wu-claim.js.map +1 -0
  213. package/dist/wu-cleanup-cloud.js +78 -0
  214. package/dist/wu-cleanup-cloud.js.map +1 -0
  215. package/dist/wu-cleanup.js.map +1 -0
  216. package/dist/wu-code-path-coverage.js +83 -0
  217. package/dist/wu-code-path-coverage.js.map +1 -0
  218. package/dist/wu-create-cloud.js +30 -0
  219. package/dist/wu-create-cloud.js.map +1 -0
  220. package/dist/wu-create-content.js +264 -0
  221. package/dist/wu-create-content.js.map +1 -0
  222. package/dist/wu-create-readiness.js +59 -0
  223. package/dist/wu-create-readiness.js.map +1 -0
  224. package/dist/wu-create-validation.js +128 -0
  225. package/dist/wu-create-validation.js.map +1 -0
  226. package/dist/wu-create.js.map +1 -0
  227. package/dist/wu-delegate.js.map +1 -0
  228. package/dist/wu-delete.js.map +1 -0
  229. package/dist/wu-deps.js.map +1 -0
  230. package/dist/wu-done-auto-cleanup.js +194 -0
  231. package/dist/wu-done-auto-cleanup.js.map +1 -0
  232. package/dist/wu-done-check.js +38 -0
  233. package/dist/wu-done-check.js.map +1 -0
  234. package/dist/wu-done-cloud.js +48 -0
  235. package/dist/wu-done-cloud.js.map +1 -0
  236. package/dist/wu-done-decay.js +83 -0
  237. package/dist/wu-done-decay.js.map +1 -0
  238. package/dist/wu-done.js.map +1 -0
  239. package/dist/wu-edit-operations.js +399 -0
  240. package/dist/wu-edit-operations.js.map +1 -0
  241. package/dist/wu-edit-validators.js +282 -0
  242. package/dist/wu-edit-validators.js.map +1 -0
  243. package/dist/wu-edit.js.map +1 -0
  244. package/dist/wu-infer-lane.js.map +1 -0
  245. package/dist/wu-preflight.js.map +1 -0
  246. package/dist/wu-prep.js.map +1 -0
  247. package/dist/wu-proto.js.map +1 -0
  248. package/dist/wu-prune.js.map +1 -0
  249. package/dist/wu-recover.js.map +1 -0
  250. package/dist/wu-release.js.map +1 -0
  251. package/dist/wu-repair.js.map +1 -0
  252. package/dist/wu-sandbox.js.map +1 -0
  253. package/dist/wu-spawn-completion.js +58 -0
  254. package/dist/wu-spawn-completion.js.map +1 -0
  255. package/dist/wu-spawn-prompt-builders.js +1190 -0
  256. package/dist/wu-spawn-prompt-builders.js.map +1 -0
  257. package/dist/wu-spawn-strategy-resolver.js +322 -0
  258. package/dist/wu-spawn-strategy-resolver.js.map +1 -0
  259. package/dist/wu-spawn.js +59 -0
  260. package/dist/wu-spawn.js.map +1 -0
  261. package/dist/wu-state-cloud.js +41 -0
  262. package/dist/wu-state-cloud.js.map +1 -0
  263. package/dist/wu-status.js.map +1 -0
  264. package/dist/wu-unblock.js.map +1 -0
  265. package/dist/wu-unlock-lane.js.map +1 -0
  266. package/dist/wu-validate.js.map +1 -0
  267. package/package.json +8 -10
package/dist/release.js CHANGED
@@ -4,15 +4,16 @@
4
4
  /**
5
5
  * Release Command
6
6
  *
7
- * Orchestrates npm release for all @lumenflow/* packages using micro-worktree isolation.
7
+ * Orchestrates npm release for all @lumenflow/* packages.
8
8
  *
9
9
  * Features:
10
10
  * - Validates semver version format
11
11
  * - Bumps all @lumenflow/* package versions atomically
12
- * - Uses micro-worktree isolation for version commit (no main branch pollution)
13
12
  * - Builds all packages via turbo
13
+ * - Validates packed artifacts against package contracts
14
14
  * - Publishes to npm with proper auth (requires NPM_TOKEN)
15
15
  * - Creates git tag vX.Y.Z
16
+ * - Cleanup-on-failure ensures main is never left dirty (WU-2062)
16
17
  *
17
18
  * Usage:
18
19
  * pnpm release --release-version 1.3.0
@@ -25,21 +26,18 @@
25
26
  * WU-1074: Add release command for npm publishing
26
27
  */
27
28
  import { Command } from 'commander';
28
- import { existsSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
29
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync, } from 'node:fs';
29
30
  import { readFile, writeFile } from 'node:fs/promises';
30
- import { join } from 'node:path';
31
+ import { dirname, join } from 'node:path';
31
32
  import { homedir } from 'node:os';
32
- import { execSync } from 'node:child_process';
33
+ import { execFileSync, execSync } from 'node:child_process';
33
34
  import { getGitForCwd } from '@lumenflow/core/git-adapter';
34
35
  import { die } from '@lumenflow/core/error-handler';
35
- import { withMicroWorktree } from '@lumenflow/core/micro-worktree';
36
36
  import { ensureOnMain } from '@lumenflow/core/wu-helpers';
37
37
  import { REMOTES, FILE_SYSTEM, PKG_MANAGER } from '@lumenflow/core/wu-constants';
38
38
  import { runCLI } from './cli-entry-point.js';
39
39
  /** Log prefix for console output */
40
40
  const LOG_PREFIX = '[release]';
41
- /** Micro-worktree operation name */
42
- const OPERATION_NAME = 'release';
43
41
  /** Directory containing @lumenflow packages */
44
42
  const LUMENFLOW_PACKAGES_DIR = 'packages/@lumenflow';
45
43
  /** Path to the bare lumenflow wrapper package (WU-1691) */
@@ -65,6 +63,52 @@ const CHANGESET_DIR = '.changeset';
65
63
  const LUMENFLOW_FORCE_ENV = 'LUMENFLOW_FORCE';
66
64
  /** Environment variable to provide reason for force bypass */
67
65
  const LUMENFLOW_FORCE_REASON_ENV = 'LUMENFLOW_FORCE_REASON';
66
+ /** Release phase label for pre-release clean-tree validation */
67
+ const RELEASE_CLEAN_CHECK_PHASE_BEFORE_RELEASE = 'before release';
68
+ /** Command shown when release fails due to dirty working tree */
69
+ const GIT_STATUS_SHORT_COMMAND = 'git status --short';
70
+ /** Guidance shown when generated artifacts dirty the repository */
71
+ const CLEAN_TREE_RECOVERY_GUIDANCE = 'Commit, stash, or clean generated files before retrying release.';
72
+ /** Package manifest filename */
73
+ const PACKAGE_JSON_FILENAME = 'package.json';
74
+ /** Source directory name used for build sanity checks */
75
+ const SOURCE_DIR_NAME = 'src';
76
+ /** Dist directory name used for release artifacts */
77
+ const DIST_DIR_NAME = 'dist';
78
+ /** Relative path prefix used in package manifests */
79
+ const RELATIVE_PATH_PREFIX = './';
80
+ /** Previous-pack sanity threshold (current must be >= 10% of previous) */
81
+ const PREVIOUS_PACK_MIN_RATIO = 0.1;
82
+ /** Path prefix emitted by some pack tools (for example package/dist/index.js) */
83
+ const PACK_TOOL_PACKAGE_PREFIX = 'package/';
84
+ /** Path prefixes used when normalizing manifest paths */
85
+ const NODE_PROTOCOL_PREFIX = 'node:';
86
+ const HTTP_PROTOCOL_PREFIX = 'http://';
87
+ const HTTPS_PROTOCOL_PREFIX = 'https://';
88
+ const PARENT_RELATIVE_PREFIX = '../';
89
+ /** Path separator constants for normalization */
90
+ const POSIX_PATH_SEPARATOR = '/';
91
+ const WINDOWS_PATH_SEPARATOR = '\\';
92
+ /** Labels for release logging phases */
93
+ const PRE_FLIGHT_LABEL = 'preflight';
94
+ const PACK_VALIDATE_LABEL = 'pack:validate';
95
+ const PACK_BASELINE_LABEL = 'pack:baseline';
96
+ /** Pack validation error messages */
97
+ const PACK_EMPTY_OUTPUT_ERROR = 'pack command produced empty output';
98
+ const PACK_INVALID_JSON_ERROR = 'pack command produced invalid JSON payload';
99
+ const PACK_MISSING_FILES_ARRAY_ERROR = 'pack command JSON missing required files[]';
100
+ const PACK_INVALID_FILES_ENTRY_ERROR = 'pack command JSON has invalid files[] entry';
101
+ const PACK_ZERO_FILES_ERROR = 'pack dry-run returned zero files';
102
+ const DIST_EMPTY_ERROR = 'dist directory has no files after build';
103
+ const MISSING_CONTRACT_PREFIX = 'Missing packaged files declared by package.json contract: ';
104
+ const RELEASE_VALIDATION_FAILURE_HEADER = 'Release artifact validation failed. Refusing to publish broken tarballs.';
105
+ const RELEASE_VALIDATION_FAILURE_FOOTER = 'Fix the package exports/build outputs, then re-run release.';
106
+ const DIST_BUILD_INCOMPLETE_PREFIX = 'dist has fewer files than src';
107
+ const DIST_BUILD_INCOMPLETE_SUFFIX = 'build artifacts look incomplete';
108
+ const PACK_COUNT_BELOW_BASELINE_PREFIX = 'packed file count';
109
+ const PACK_COUNT_BELOW_BASELINE_MID = 'is below 10% of previous published version';
110
+ const SKIP_BUILD_SYMLINK_ERROR_PREFIX = 'Refusing release with --skip-build:';
111
+ const SKIP_BUILD_SYMLINK_ERROR_GUIDANCE = 'Run release without --skip-build so dist can be rebuilt as real files.';
68
112
  /**
69
113
  * Environment variable for WU tool identification (WU-1296)
70
114
  * Pre-push hook checks this to allow approved tool operations
@@ -101,6 +145,22 @@ export async function withReleaseEnv(fn) {
101
145
  }
102
146
  }
103
147
  }
148
+ /**
149
+ * Assert the current git working tree is clean.
150
+ *
151
+ * @param git - Git adapter with cleanliness check
152
+ * @param phase - Human-readable release phase label
153
+ */
154
+ export async function assertWorkingTreeClean(git, phase) {
155
+ const isClean = await git.isClean();
156
+ if (isClean) {
157
+ return;
158
+ }
159
+ die(`Working directory has uncommitted changes ${phase}.\n\n` +
160
+ `Run this command to inspect unexpected artifacts:\n` +
161
+ ` ${GIT_STATUS_SHORT_COMMAND}\n` +
162
+ `${CLEAN_TREE_RECOVERY_GUIDANCE}`);
163
+ }
104
164
  /**
105
165
  * Validate that a string is a valid semver version
106
166
  *
@@ -128,7 +188,7 @@ export function findPackageJsonPaths(baseDir = process.cwd()) {
128
188
  const entries = readdirSync(packagesDir);
129
189
  for (const entry of entries) {
130
190
  const entryPath = join(packagesDir, entry);
131
- const packageJsonPath = join(entryPath, 'package.json');
191
+ const packageJsonPath = join(entryPath, PACKAGE_JSON_FILENAME);
132
192
  if (statSync(entryPath).isDirectory() && existsSync(packageJsonPath)) {
133
193
  // Read package.json to check if it's private
134
194
  const content = JSON.parse(readFileSync(packageJsonPath, { encoding: FILE_SYSTEM.UTF8 }));
@@ -140,7 +200,7 @@ export function findPackageJsonPaths(baseDir = process.cwd()) {
140
200
  }
141
201
  }
142
202
  // WU-1691: Include the bare lumenflow wrapper package
143
- const wrapperPackageJson = join(baseDir, LUMENFLOW_WRAPPER_PACKAGE, 'package.json');
203
+ const wrapperPackageJson = join(baseDir, LUMENFLOW_WRAPPER_PACKAGE, PACKAGE_JSON_FILENAME);
144
204
  if (existsSync(wrapperPackageJson)) {
145
205
  const content = JSON.parse(readFileSync(wrapperPackageJson, { encoding: FILE_SYSTEM.UTF8 }));
146
206
  if (!content.private) {
@@ -168,6 +228,227 @@ export async function updatePackageVersions(paths, version) {
168
228
  await writeFile(packagePath, updated, { encoding: FILE_SYSTEM.ENCODING });
169
229
  }
170
230
  }
231
+ /**
232
+ * Convert manifest file paths (for example "./dist/index.js") into package-relative paths.
233
+ */
234
+ function normalizeManifestPath(filePath) {
235
+ const trimmed = filePath.trim();
236
+ if (!trimmed) {
237
+ return null;
238
+ }
239
+ if (trimmed.startsWith(NODE_PROTOCOL_PREFIX) ||
240
+ trimmed.startsWith(HTTP_PROTOCOL_PREFIX) ||
241
+ trimmed.startsWith(HTTPS_PROTOCOL_PREFIX)) {
242
+ return null;
243
+ }
244
+ if (trimmed.startsWith(RELATIVE_PATH_PREFIX)) {
245
+ return trimmed
246
+ .slice(RELATIVE_PATH_PREFIX.length)
247
+ .replaceAll(WINDOWS_PATH_SEPARATOR, POSIX_PATH_SEPARATOR);
248
+ }
249
+ if (trimmed.startsWith(PARENT_RELATIVE_PREFIX) || trimmed.includes(POSIX_PATH_SEPARATOR)) {
250
+ return trimmed.replaceAll(WINDOWS_PATH_SEPARATOR, POSIX_PATH_SEPARATOR);
251
+ }
252
+ return null;
253
+ }
254
+ /**
255
+ * Normalize packed tarball paths across npm/pnpm variants.
256
+ *
257
+ * Some pack tools emit "package/dist/index.js" while others emit "dist/index.js".
258
+ * Contract comparisons use package-relative paths, so strip the optional prefix.
259
+ */
260
+ function normalizePackedPath(filePath) {
261
+ const normalized = normalizeManifestPath(filePath);
262
+ if (!normalized) {
263
+ return null;
264
+ }
265
+ if (normalized.startsWith(PACK_TOOL_PACKAGE_PREFIX)) {
266
+ return normalized.slice(PACK_TOOL_PACKAGE_PREFIX.length);
267
+ }
268
+ return normalized;
269
+ }
270
+ function buildDistCountMismatchError(distFileCount, srcFileCount) {
271
+ return `${DIST_BUILD_INCOMPLETE_PREFIX} (${distFileCount} < ${srcFileCount}), ${DIST_BUILD_INCOMPLETE_SUFFIX}`;
272
+ }
273
+ function buildPackBaselineThresholdError(packedFileCount, previousPackedFileCount) {
274
+ return `${PACK_COUNT_BELOW_BASELINE_PREFIX} ${packedFileCount} ${PACK_COUNT_BELOW_BASELINE_MID} (${previousPackedFileCount})`;
275
+ }
276
+ function buildSkipBuildSymlinkError(relativeDistPath) {
277
+ return (`${SKIP_BUILD_SYMLINK_ERROR_PREFIX} ${relativeDistPath} is a symlink.\n` +
278
+ `${SKIP_BUILD_SYMLINK_ERROR_GUIDANCE}`);
279
+ }
280
+ function buildWorkspacePackDryRunCommand(packageName) {
281
+ return `${PKG_MANAGER} --filter "${packageName}" pack --json --dry-run`;
282
+ }
283
+ function buildLatestPublishedPackDryRunCommand(packageName) {
284
+ return `npm pack "${packageName}@latest" --json --dry-run`;
285
+ }
286
+ /**
287
+ * Collect all string leaf values from nested export conditions.
288
+ */
289
+ function collectLeafStringValues(value, collector) {
290
+ if (typeof value === 'string') {
291
+ collector.push(value);
292
+ return;
293
+ }
294
+ if (Array.isArray(value)) {
295
+ for (const entry of value) {
296
+ collectLeafStringValues(entry, collector);
297
+ }
298
+ return;
299
+ }
300
+ if (value && typeof value === 'object') {
301
+ for (const entry of Object.values(value)) {
302
+ collectLeafStringValues(entry, collector);
303
+ }
304
+ }
305
+ }
306
+ /**
307
+ * Derive package-file contract paths from package.json fields.
308
+ *
309
+ * Contract source of truth:
310
+ * - exports map (all subpath leaf values)
311
+ * - main/types
312
+ * - bin targets
313
+ */
314
+ export function extractPackageContractPaths(manifest) {
315
+ const rawPaths = [];
316
+ if (typeof manifest.main === 'string') {
317
+ rawPaths.push(manifest.main);
318
+ }
319
+ if (typeof manifest.types === 'string') {
320
+ rawPaths.push(manifest.types);
321
+ }
322
+ if (typeof manifest.bin === 'string') {
323
+ rawPaths.push(manifest.bin);
324
+ }
325
+ else if (manifest.bin && typeof manifest.bin === 'object') {
326
+ rawPaths.push(...Object.values(manifest.bin));
327
+ }
328
+ if (manifest.exports !== undefined) {
329
+ collectLeafStringValues(manifest.exports, rawPaths);
330
+ }
331
+ const deduped = new Set();
332
+ for (const rawPath of rawPaths) {
333
+ const normalized = normalizeManifestPath(rawPath);
334
+ if (normalized) {
335
+ deduped.add(normalized);
336
+ }
337
+ }
338
+ return [...deduped];
339
+ }
340
+ /**
341
+ * Determine if this package publishes dist artifacts.
342
+ */
343
+ function packageExpectsDist(manifest, contractPaths) {
344
+ const files = manifest.files ?? [];
345
+ const includesDistInFiles = files.some((entry) => {
346
+ const normalized = entry
347
+ .trim()
348
+ .replaceAll(WINDOWS_PATH_SEPARATOR, POSIX_PATH_SEPARATOR)
349
+ .replace(/\/+$/, '');
350
+ return normalized === DIST_DIR_NAME || normalized.startsWith(`${DIST_DIR_NAME}/`);
351
+ });
352
+ return (includesDistInFiles || contractPaths.some((entry) => entry.startsWith(`${DIST_DIR_NAME}/`)));
353
+ }
354
+ /**
355
+ * Count files recursively for sanity checks.
356
+ */
357
+ function countFilesRecursive(pathToCount) {
358
+ if (!existsSync(pathToCount)) {
359
+ return 0;
360
+ }
361
+ const stat = lstatSync(pathToCount);
362
+ if (!stat.isDirectory()) {
363
+ return 0;
364
+ }
365
+ let fileCount = 0;
366
+ const entries = readdirSync(pathToCount, { withFileTypes: true });
367
+ for (const entry of entries) {
368
+ const absolutePath = join(pathToCount, entry.name);
369
+ if (entry.isDirectory()) {
370
+ fileCount += countFilesRecursive(absolutePath);
371
+ }
372
+ else if (entry.isFile()) {
373
+ fileCount += 1;
374
+ }
375
+ }
376
+ return fileCount;
377
+ }
378
+ /**
379
+ * Validate packed artifacts against package contract paths and dynamic sanity checks.
380
+ */
381
+ export function validatePackedArtifacts(input) {
382
+ const contractPaths = extractPackageContractPaths(input.manifest);
383
+ const normalizedPackedFiles = input.packedFiles
384
+ .map((entry) => normalizePackedPath(entry))
385
+ .filter((entry) => entry !== null);
386
+ const packedSet = new Set(normalizedPackedFiles);
387
+ const missingContractPaths = contractPaths.filter((entry) => !packedSet.has(entry));
388
+ const errors = [];
389
+ if (input.packedFiles.length === 0) {
390
+ errors.push(PACK_ZERO_FILES_ERROR);
391
+ }
392
+ if (missingContractPaths.length > 0) {
393
+ errors.push(`${MISSING_CONTRACT_PREFIX}${missingContractPaths.join(', ')}`);
394
+ }
395
+ const expectsDist = packageExpectsDist(input.manifest, contractPaths);
396
+ if (expectsDist) {
397
+ if (input.distFileCount === 0) {
398
+ errors.push(DIST_EMPTY_ERROR);
399
+ }
400
+ else if (input.srcFileCount > 0 && input.distFileCount < input.srcFileCount) {
401
+ errors.push(buildDistCountMismatchError(input.distFileCount, input.srcFileCount));
402
+ }
403
+ }
404
+ if (input.previousPackedFileCount !== undefined && input.previousPackedFileCount > 0) {
405
+ const minimumExpected = Math.max(1, Math.ceil(input.previousPackedFileCount * PREVIOUS_PACK_MIN_RATIO));
406
+ if (input.packedFiles.length < minimumExpected) {
407
+ errors.push(buildPackBaselineThresholdError(input.packedFiles.length, input.previousPackedFileCount));
408
+ }
409
+ }
410
+ return {
411
+ ok: errors.length === 0,
412
+ packageName: input.packageName,
413
+ contractPaths,
414
+ missingContractPaths,
415
+ errors,
416
+ };
417
+ }
418
+ /**
419
+ * Replace symlinked dist directories with real directories.
420
+ *
421
+ * This avoids npm pack/publish inconsistencies from cross-worktree dist symlinks.
422
+ */
423
+ export function ensureDistPathsMaterialized(packageDirs, options = {}) {
424
+ const { skipBuild = false, dryRun = false } = options;
425
+ let checkedCount = 0;
426
+ let materializedCount = 0;
427
+ for (const packageDir of packageDirs) {
428
+ const distPath = join(packageDir, DIST_DIR_NAME);
429
+ if (!existsSync(distPath)) {
430
+ continue;
431
+ }
432
+ const distStat = lstatSync(distPath);
433
+ if (!distStat.isSymbolicLink()) {
434
+ continue;
435
+ }
436
+ checkedCount += 1;
437
+ const relativeDistPath = distPath.replace(`${process.cwd()}/`, '');
438
+ if (skipBuild) {
439
+ die(buildSkipBuildSymlinkError(relativeDistPath));
440
+ }
441
+ if (dryRun) {
442
+ console.log(`${LOG_PREFIX} [${PRE_FLIGHT_LABEL}] Would materialize symlinked dist at ${relativeDistPath}`);
443
+ continue;
444
+ }
445
+ rmSync(distPath, { recursive: true, force: true });
446
+ mkdirSync(distPath, { recursive: true });
447
+ materializedCount += 1;
448
+ console.log(`${LOG_PREFIX} [${PRE_FLIGHT_LABEL}] Materialized symlinked dist at ${relativeDistPath}`);
449
+ }
450
+ return { checkedCount, materializedCount };
451
+ }
171
452
  /**
172
453
  * Build commit message for version bump
173
454
  *
@@ -186,16 +467,6 @@ export function buildCommitMessage(version) {
186
467
  export function buildTagName(version) {
187
468
  return `v${version}`;
188
469
  }
189
- /**
190
- * Get relative path from worktree root
191
- *
192
- * @param absolutePath - Absolute file path
193
- * @param worktreePath - Worktree root path
194
- * @returns Relative path
195
- */
196
- function getRelativePath(absolutePath, worktreePath) {
197
- return absolutePath.replace(worktreePath + '/', '');
198
- }
199
470
  /**
200
471
  * Execute a shell command and handle errors
201
472
  *
@@ -221,6 +492,188 @@ function runCommand(cmd, options = {}) {
221
492
  throw new Error(`Command failed: ${cmd}`, { cause: error });
222
493
  }
223
494
  }
495
+ /**
496
+ * Execute a shell command and capture stdout.
497
+ */
498
+ function runCommandCapture(cmd, options = {}) {
499
+ const { cwd = process.cwd(), label } = options;
500
+ const prefix = label ? `[${label}] ` : '';
501
+ console.log(`${LOG_PREFIX} ${prefix}Running: ${cmd}`);
502
+ try {
503
+ return execSync(cmd, {
504
+ cwd,
505
+ stdio: ['ignore', 'pipe', 'pipe'],
506
+ encoding: FILE_SYSTEM.ENCODING,
507
+ });
508
+ }
509
+ catch (error) {
510
+ throw new Error(`Command failed: ${cmd}`, { cause: error });
511
+ }
512
+ }
513
+ /** Characters that can start a valid JSON value from pack commands */
514
+ const JSON_ARRAY_START = '[';
515
+ const JSON_OBJECT_START = '{';
516
+ /**
517
+ * Find the index of the first real JSON-start sequence in a string.
518
+ *
519
+ * pnpm lifecycle scripts can prepend non-JSON output to stdout before the
520
+ * actual JSON payload — including log-style brackets like `[sync:bundled-packs]`.
521
+ * A bare `[` is ambiguous, so this function peeks at the next non-whitespace
522
+ * character to distinguish JSON arrays (`[{`, `["`, `[]`) and objects (`{"`, `{}`)
523
+ * from log prefixes.
524
+ *
525
+ * WU-2062: Replaces naive indexOf('[') which matched log brackets.
526
+ *
527
+ * @returns Index of the first valid JSON start, or 0 if not found (let JSON.parse report the error)
528
+ */
529
+ export function findJsonStartIndex(raw) {
530
+ for (let i = 0; i < raw.length; i++) {
531
+ const ch = raw[i];
532
+ if (ch !== JSON_ARRAY_START && ch !== JSON_OBJECT_START) {
533
+ continue;
534
+ }
535
+ // Peek past optional whitespace to verify this starts a JSON value,
536
+ // not a log-style bracket like [sync:bundled-packs].
537
+ let j = i + 1;
538
+ while (j < raw.length &&
539
+ (raw[j] === ' ' || raw[j] === '\t' || raw[j] === '\n' || raw[j] === '\r')) {
540
+ j++;
541
+ }
542
+ if (j >= raw.length) {
543
+ return i;
544
+ }
545
+ const next = raw[j];
546
+ // Valid JSON array starts: [{ , [" , [] (empty)
547
+ if (ch === JSON_ARRAY_START && (next === JSON_OBJECT_START || next === '"' || next === ']')) {
548
+ return i;
549
+ }
550
+ // Valid JSON object starts: {" , {} (empty)
551
+ if (ch === JSON_OBJECT_START && (next === '"' || next === '}')) {
552
+ return i;
553
+ }
554
+ }
555
+ // Not found — return 0 so JSON.parse reports the actual error
556
+ return 0;
557
+ }
558
+ /**
559
+ * Parse JSON output from npm/pnpm pack dry runs.
560
+ *
561
+ * pnpm lifecycle scripts (prepack/postpack) can emit non-JSON text to stdout
562
+ * before the actual JSON payload. This function strips such prefix noise by
563
+ * locating the first `[` or `{` character — the start of valid JSON.
564
+ */
565
+ export function parsePackDryRunMetadata(rawOutput) {
566
+ const trimmed = rawOutput.trim();
567
+ if (!trimmed) {
568
+ throw new Error(PACK_EMPTY_OUTPUT_ERROR);
569
+ }
570
+ // pnpm lifecycle scripts (prepack/postpack) can emit text to stdout before
571
+ // the JSON payload. Strip everything before the first JSON-start character.
572
+ const jsonStartIndex = findJsonStartIndex(trimmed);
573
+ const jsonPayload = jsonStartIndex > 0 ? trimmed.slice(jsonStartIndex) : trimmed;
574
+ const parsed = JSON.parse(jsonPayload);
575
+ const normalized = Array.isArray(parsed) ? parsed[0] : parsed;
576
+ if (!normalized || typeof normalized !== 'object') {
577
+ throw new Error(PACK_INVALID_JSON_ERROR);
578
+ }
579
+ const files = normalized.files;
580
+ if (!Array.isArray(files)) {
581
+ throw new Error(PACK_MISSING_FILES_ARRAY_ERROR);
582
+ }
583
+ for (const file of files) {
584
+ if (!file ||
585
+ typeof file !== 'object' ||
586
+ typeof file.path !== 'string') {
587
+ throw new Error(PACK_INVALID_FILES_ENTRY_ERROR);
588
+ }
589
+ }
590
+ const entryCount = normalized.entryCount;
591
+ return {
592
+ files: files,
593
+ entryCount: typeof entryCount === 'number' ? entryCount : undefined,
594
+ };
595
+ }
596
+ /**
597
+ * Read package manifest for release validation.
598
+ */
599
+ function readPackageManifest(packageJsonPath) {
600
+ return JSON.parse(readFileSync(packageJsonPath, {
601
+ encoding: FILE_SYSTEM.UTF8,
602
+ }));
603
+ }
604
+ /**
605
+ * Resolve packed file list for a workspace package via pnpm pack --dry-run.
606
+ */
607
+ function getWorkspacePackedFiles(packageName) {
608
+ const output = runCommandCapture(buildWorkspacePackDryRunCommand(packageName), {
609
+ label: PACK_VALIDATE_LABEL,
610
+ });
611
+ const metadata = parsePackDryRunMetadata(output);
612
+ return metadata.files
613
+ .map((entry) => normalizePackedPath(entry.path))
614
+ .filter((entry) => entry !== null);
615
+ }
616
+ /**
617
+ * Resolve packed file count for the currently published npm version.
618
+ */
619
+ function getPreviousPublishedPackFileCount(packageName) {
620
+ try {
621
+ const output = runCommandCapture(buildLatestPublishedPackDryRunCommand(packageName), {
622
+ label: PACK_BASELINE_LABEL,
623
+ });
624
+ const metadata = parsePackDryRunMetadata(output);
625
+ const entryCount = metadata.entryCount;
626
+ if (typeof entryCount === 'number') {
627
+ return entryCount;
628
+ }
629
+ return metadata.files.length;
630
+ }
631
+ catch (error) {
632
+ const message = error instanceof Error ? error.message : String(error);
633
+ console.warn(`${LOG_PREFIX} [${PACK_BASELINE_LABEL}] Skipping previous-version count for ${packageName}: ${message}`);
634
+ return undefined;
635
+ }
636
+ }
637
+ /**
638
+ * Validate release package artifacts before tag/publish.
639
+ */
640
+ function validateReleaseArtifactsForPublish(packageJsonPaths, dryRun) {
641
+ if (dryRun) {
642
+ console.log(`${LOG_PREFIX} [${PACK_VALIDATE_LABEL}] Would validate packed artifacts against package contracts`);
643
+ return;
644
+ }
645
+ const failures = [];
646
+ for (const packageJsonPath of packageJsonPaths) {
647
+ const packageDir = dirname(packageJsonPath);
648
+ const manifest = readPackageManifest(packageJsonPath);
649
+ const packageName = manifest.name ?? packageDir;
650
+ const packedFiles = getWorkspacePackedFiles(packageName);
651
+ const previousPackedFileCount = getPreviousPublishedPackFileCount(packageName);
652
+ const srcFileCount = countFilesRecursive(join(packageDir, SOURCE_DIR_NAME));
653
+ const distFileCount = countFilesRecursive(join(packageDir, DIST_DIR_NAME));
654
+ const result = validatePackedArtifacts({
655
+ packageName,
656
+ packageDir,
657
+ manifest,
658
+ packedFiles,
659
+ srcFileCount,
660
+ distFileCount,
661
+ previousPackedFileCount,
662
+ });
663
+ if (!result.ok) {
664
+ failures.push(result);
665
+ }
666
+ }
667
+ if (failures.length > 0) {
668
+ const details = failures
669
+ .map((failure) => `- ${failure.packageName}\n` + failure.errors.map((error) => ` - ${error}`).join('\n'))
670
+ .join('\n');
671
+ die(`${RELEASE_VALIDATION_FAILURE_HEADER}\n\n` +
672
+ `${details}\n\n` +
673
+ `${RELEASE_VALIDATION_FAILURE_FOOTER}`);
674
+ }
675
+ console.log(`${LOG_PREFIX} ✅ Packed artifact validation passed for ${packageJsonPaths.length} packages`);
676
+ }
224
677
  /**
225
678
  * Check if npm authentication is available
226
679
  *
@@ -313,6 +766,63 @@ export async function pushTagWithForce(git, tagName, reason = 'release: tag push
313
766
  }
314
767
  }
315
768
  }
769
+ /**
770
+ * Clean up release artifacts after a failed release attempt.
771
+ *
772
+ * WU-2062: Ensures main is never left dirty by a failed release. Handles:
773
+ * 1. Materialized dist directories (symlinks replaced with real dirs by preflight)
774
+ * 2. Generated packs/ directories (from prepack lifecycle scripts)
775
+ * 3. Version-bumped package.json files (from Phase 2)
776
+ *
777
+ * After cleanup, all tracked files are restored to HEAD state and untracked
778
+ * generated files are removed.
779
+ */
780
+ async function cleanupFailedRelease(packageDirs) {
781
+ console.error(`\n${LOG_PREFIX} ⚠️ Release failed — cleaning up artifacts on main...`);
782
+ try {
783
+ // 1. Remove materialized dist directories so git can restore symlinks.
784
+ // ensureDistPathsMaterialized replaces symlinks with real dirs; build fills
785
+ // them with untracked output. Must delete before git checkout can recreate
786
+ // the tracked symlink.
787
+ for (const packageDir of packageDirs) {
788
+ const distPath = join(packageDir, DIST_DIR_NAME);
789
+ if (existsSync(distPath) && !lstatSync(distPath).isSymbolicLink()) {
790
+ rmSync(distPath, { recursive: true, force: true });
791
+ }
792
+ }
793
+ // 2. Remove untracked generated files (e.g., packs/ from prepack lifecycle scripts).
794
+ // These are created by sync-bundled-packs.mjs during pack --dry-run but may not
795
+ // be cleaned up if the release script fails before postpack runs.
796
+ for (const packageDir of packageDirs) {
797
+ const packsDir = join(packageDir, 'packs');
798
+ if (existsSync(packsDir)) {
799
+ rmSync(packsDir, { recursive: true, force: true });
800
+ const relativePath = packsDir.replace(`${process.cwd()}/`, '');
801
+ console.error(`${LOG_PREFIX} Removed generated ${relativePath}`);
802
+ }
803
+ }
804
+ // 3. Restore all tracked files to HEAD state (dist symlinks + package.json versions).
805
+ // Safe because assertWorkingTreeClean verified the tree was clean before we started.
806
+ const git = getGitForCwd();
807
+ await git.raw(['checkout', '--', '.']);
808
+ // 4. Reinstall dependencies to restore workspace symlinks.
809
+ // WU-2063: git checkout -- . can change package.json files and dist symlinks,
810
+ // which invalidates pnpm's link state. Running pnpm install ensures
811
+ // workspace package links in node_modules/ are consistent.
812
+ console.error(`${LOG_PREFIX} Restoring workspace links...`);
813
+ execFileSync(PKG_MANAGER, ['install'], {
814
+ cwd: process.cwd(),
815
+ stdio: 'inherit',
816
+ encoding: FILE_SYSTEM.ENCODING,
817
+ });
818
+ console.error(`${LOG_PREFIX} ✅ Cleanup complete — main is clean`);
819
+ }
820
+ catch (cleanupError) {
821
+ const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
822
+ console.error(`${LOG_PREFIX} ⚠️ Automatic cleanup failed: ${message}`);
823
+ console.error(`${LOG_PREFIX} Manual recovery: git checkout -- . && git clean -fd`);
824
+ }
825
+ }
316
826
  /**
317
827
  * Main release function
318
828
  * WU-1085: Renamed --version to --release-version to avoid conflict with CLI --version flag
@@ -345,13 +855,7 @@ export async function main() {
345
855
  const git = getGitForCwd();
346
856
  await ensureOnMain(git);
347
857
  // Check for uncommitted changes
348
- const isClean = await git.isClean();
349
- if (!isClean) {
350
- die(`Working directory has uncommitted changes.\n\n` +
351
- `Commit or stash changes before releasing:\n` +
352
- ` git status\n` +
353
- ` git stash # or git commit`);
354
- }
858
+ await assertWorkingTreeClean(git, RELEASE_CLEAN_CHECK_PHASE_BEFORE_RELEASE);
355
859
  // Find all @lumenflow/* packages to update
356
860
  const packagePaths = findPackageJsonPaths();
357
861
  if (packagePaths.length === 0) {
@@ -361,6 +865,7 @@ export async function main() {
361
865
  for (const p of packagePaths) {
362
866
  console.log(` - ${p.replace(process.cwd() + '/', '')}`);
363
867
  }
868
+ const packageDirs = packagePaths.map((path) => dirname(path));
364
869
  // Check npm authentication for publish
365
870
  if (!skipPublish && !dryRun && !hasNpmAuth()) {
366
871
  die(`npm authentication not found.\n\n` +
@@ -370,102 +875,145 @@ export async function main() {
370
875
  `Get a token at: https://www.npmjs.com/settings/tokens\n` +
371
876
  `Or use --skip-publish to only bump versions and create tag.`);
372
877
  }
373
- // Execute version bump in micro-worktree
374
- if (dryRun) {
375
- console.log(`${LOG_PREFIX} Would bump versions to ${version} using micro-worktree isolation`);
376
- console.log(`${LOG_PREFIX} Would commit: ${buildCommitMessage(version)}`);
377
- }
378
- else {
379
- console.log(`${LOG_PREFIX} Bumping versions using micro-worktree isolation...`);
380
- // WU-1296: Use withReleaseEnv to set LUMENFLOW_WU_TOOL=release
381
- // This allows the micro-worktree push to main without LUMENFLOW_FORCE
382
- await withReleaseEnv(async () => {
383
- await withMicroWorktree({
384
- operation: OPERATION_NAME,
385
- id: `v${version}`,
386
- logPrefix: LOG_PREFIX,
387
- execute: async ({ worktreePath }) => {
388
- // Check and exit changeset pre mode if active
389
- if (isInChangesetPreMode(worktreePath)) {
390
- console.log(`${LOG_PREFIX} Detected changeset pre-release mode, exiting...`);
391
- exitChangesetPreMode(worktreePath);
392
- console.log(`${LOG_PREFIX} ✅ Exited changeset pre mode`);
393
- }
394
- // Find package paths within the worktree
395
- const worktreePackagePaths = findPackageJsonPaths(worktreePath);
396
- // Update versions
397
- console.log(`${LOG_PREFIX} Updating ${worktreePackagePaths.length} package versions...`);
398
- await updatePackageVersions(worktreePackagePaths, version);
399
- // Get relative paths for commit
400
- const relativePaths = worktreePackagePaths.map((p) => getRelativePath(p, worktreePath));
401
- // If we exited pre mode, include the deleted pre.json in files to commit
402
- // (the deletion will be staged automatically by git add -A behavior)
403
- const _changesetPrePath = join(CHANGESET_DIR, CHANGESET_PRE_JSON);
404
- const filesToCommit = [...relativePaths];
405
- // Note: Deletion of pre.json is handled by git detecting the missing file
406
- console.log(`${LOG_PREFIX} ✅ Versions updated to ${version}`);
407
- return {
408
- commitMessage: buildCommitMessage(version),
409
- files: filesToCommit,
410
- };
411
- },
412
- });
413
- });
414
- console.log(`${LOG_PREFIX} ✅ Version bump committed and pushed`);
415
- }
416
- // Build packages
417
- if (!skipBuild) {
418
- runCommand(`${PKG_MANAGER} build`, { dryRun, label: 'build' });
419
- console.log(`${LOG_PREFIX} ✅ Build complete`);
420
- }
421
- else {
422
- console.log(`${LOG_PREFIX} Skipping build (--skip-build)`);
423
- }
424
- // Create git tag
425
- const tagName = buildTagName(version);
426
- if (dryRun) {
427
- console.log(`${LOG_PREFIX} Would create tag: ${tagName}`);
428
- console.log(`${LOG_PREFIX} Would push tag to ${REMOTES.ORIGIN}`);
429
- }
430
- else {
431
- console.log(`${LOG_PREFIX} Creating tag ${tagName}...`);
432
- await git.raw(['tag', '-a', tagName, '-m', `Release ${tagName}`]);
433
- console.log(`${LOG_PREFIX} ✅ Tag created: ${tagName}`);
434
- console.log(`${LOG_PREFIX} Pushing tag to ${REMOTES.ORIGIN}...`);
435
- await pushTagWithForce(git, tagName, 'release: pushing version tag');
436
- console.log(`${LOG_PREFIX} ✅ Tag pushed`);
437
- }
438
- // Publish to npm
439
- if (!skipPublish) {
878
+ // Exit changeset pre mode if active
879
+ if (isInChangesetPreMode()) {
440
880
  if (dryRun) {
441
- console.log(`${LOG_PREFIX} Would publish packages to npm`);
881
+ console.log(`${LOG_PREFIX} Would exit changeset pre-release mode`);
442
882
  }
443
883
  else {
444
- console.log(`${LOG_PREFIX} Publishing packages to npm...`);
445
- runCommand(`${PKG_MANAGER} -r publish --access public --no-git-checks`, { label: 'publish' });
446
- console.log(`${LOG_PREFIX} ✅ Packages published to npm`);
884
+ console.log(`${LOG_PREFIX} Detected changeset pre-release mode, exiting...`);
885
+ exitChangesetPreMode();
886
+ console.log(`${LOG_PREFIX} ✅ Exited changeset pre mode`);
447
887
  }
448
888
  }
449
- else {
450
- console.log(`${LOG_PREFIX} Skipping npm publish (--skip-publish)`);
451
- }
452
- // Summary
453
- console.log(`\n${LOG_PREFIX} 🎉 Release complete!`);
454
- console.log(`${LOG_PREFIX} Version: ${version}`);
455
- console.log(`${LOG_PREFIX} Tag: ${tagName}`);
456
- if (!skipPublish && !dryRun) {
457
- console.log(`${LOG_PREFIX} npm: https://www.npmjs.com/org/lumenflow`);
458
- }
459
- console.log(`\n${LOG_PREFIX} Next steps:`);
460
- if (dryRun) {
461
- console.log(` - Run without --dry-run to execute the release`);
889
+ // WU-2062: Wrap all release phases in try/finally to ensure main is never
890
+ // left dirty. If anything fails before npm publish, cleanup restores main
891
+ // to its pre-release state. If npm publish succeeded but git push failed,
892
+ // we print manual recovery instructions instead of undoing the publish.
893
+ let npmPublished = false;
894
+ let releaseComplete = false;
895
+ try {
896
+ // ── Phase 1: Build & Validate (no writes to origin) ────────────────
897
+ // WU-2061: Build and validate BEFORE bumping versions or pushing anything.
898
+ // If any step here fails, main is untouched.
899
+ // Materialize symlinked dist directories for reliable pack/publish
900
+ const distPreparation = ensureDistPathsMaterialized(packageDirs, { skipBuild, dryRun });
901
+ if (distPreparation.checkedCount > 0 && !dryRun && distPreparation.materializedCount > 0) {
902
+ console.log(`${LOG_PREFIX} [${PRE_FLIGHT_LABEL}] Materialized ${distPreparation.materializedCount} symlinked dist directories`);
903
+ }
904
+ if (!skipBuild) {
905
+ runCommand(`${PKG_MANAGER} build`, { dryRun, label: 'build' });
906
+ console.log(`${LOG_PREFIX} ✅ Build complete`);
907
+ }
908
+ else {
909
+ console.log(`${LOG_PREFIX} Skipping build (--skip-build)`);
910
+ }
911
+ if (!skipPublish) {
912
+ validateReleaseArtifactsForPublish(packagePaths, Boolean(dryRun));
913
+ }
914
+ else if (dryRun) {
915
+ console.log(`${LOG_PREFIX} [${PACK_VALIDATE_LABEL}] Would skip artifact validation (--skip-publish)`);
916
+ }
917
+ // ── Phase 2: Version bump (local only, no push yet) ────────────────
918
+ // WU-2061: Bump versions locally on main. No micro-worktree needed — if
919
+ // anything fails after this, cleanup restores package.json files.
920
+ console.log(`${LOG_PREFIX} Bumping versions to ${version}...`);
921
+ if (dryRun) {
922
+ console.log(`${LOG_PREFIX} Would update ${packagePaths.length} package versions`);
923
+ console.log(`${LOG_PREFIX} Would commit: ${buildCommitMessage(version)}`);
924
+ }
925
+ else {
926
+ await updatePackageVersions(packagePaths, version);
927
+ console.log(`${LOG_PREFIX} ✅ Versions updated to ${version}`);
928
+ }
929
+ // ── Phase 3: Publish to npm ────────────────────────────────────────
930
+ // Publish BEFORE committing to git. If publish fails, cleanup restores
931
+ // package.json files and main stays clean.
932
+ if (!skipPublish) {
933
+ if (dryRun) {
934
+ console.log(`${LOG_PREFIX} Would publish packages to npm`);
935
+ }
936
+ else {
937
+ console.log(`${LOG_PREFIX} Publishing packages to npm...`);
938
+ runCommand(`${PKG_MANAGER} -r publish --access public --no-git-checks`, {
939
+ label: 'publish',
940
+ });
941
+ npmPublished = true;
942
+ console.log(`${LOG_PREFIX} ✅ Packages published to npm`);
943
+ }
944
+ }
945
+ else {
946
+ console.log(`${LOG_PREFIX} Skipping npm publish (--skip-publish)`);
947
+ }
948
+ // ── Phase 4: Commit, tag, push (only after everything succeeds) ────
949
+ // WU-2061: This is the point of no return. Build passed, validation
950
+ // passed, npm publish succeeded. Now commit the version bump to main.
951
+ const tagName = buildTagName(version);
952
+ if (dryRun) {
953
+ console.log(`${LOG_PREFIX} Would commit: ${buildCommitMessage(version)}`);
954
+ console.log(`${LOG_PREFIX} Would create tag: ${tagName}`);
955
+ console.log(`${LOG_PREFIX} Would push to ${REMOTES.ORIGIN}`);
956
+ }
957
+ else {
958
+ // Format changed files before committing
959
+ const relativePaths = packagePaths.map((p) => p.replace(process.cwd() + '/', ''));
960
+ runCommand(`${PKG_MANAGER} prettier --write ${relativePaths.join(' ')}`, {
961
+ label: 'format',
962
+ });
963
+ // Stage and commit
964
+ await git.add(relativePaths);
965
+ await git.commit(buildCommitMessage(version));
966
+ console.log(`${LOG_PREFIX} ✅ Committed: ${buildCommitMessage(version)}`);
967
+ // Create and push tag
968
+ console.log(`${LOG_PREFIX} Creating tag ${tagName}...`);
969
+ await git.raw(['tag', '-a', tagName, '-m', `Release ${tagName}`]);
970
+ console.log(`${LOG_PREFIX} ✅ Tag created: ${tagName}`);
971
+ // Push commit + tag together
972
+ console.log(`${LOG_PREFIX} Pushing to ${REMOTES.ORIGIN}...`);
973
+ await withReleaseEnv(async () => {
974
+ await git.push(REMOTES.ORIGIN, 'main');
975
+ await git.push(REMOTES.ORIGIN, tagName);
976
+ });
977
+ console.log(`${LOG_PREFIX} ✅ Pushed to ${REMOTES.ORIGIN}`);
978
+ }
979
+ releaseComplete = true;
980
+ // Summary
981
+ console.log(`\n${LOG_PREFIX} 🎉 Release complete!`);
982
+ console.log(`${LOG_PREFIX} Version: ${version}`);
983
+ console.log(`${LOG_PREFIX} Tag: ${tagName}`);
984
+ if (!skipPublish && !dryRun) {
985
+ console.log(`${LOG_PREFIX} npm: https://www.npmjs.com/org/lumenflow`);
986
+ }
987
+ console.log(`\n${LOG_PREFIX} Next steps:`);
988
+ if (dryRun) {
989
+ console.log(` - Run without --dry-run to execute the release`);
990
+ }
991
+ else {
992
+ console.log(` - Create GitHub release: gh release create ${tagName} --title "Release ${tagName}"`);
993
+ if (skipPublish) {
994
+ console.log(` - Publish to npm: ${PKG_MANAGER} -r publish --access public --no-git-checks`);
995
+ }
996
+ console.log(` - Verify packages: npm view @lumenflow/cli version`);
997
+ }
462
998
  }
463
- else {
464
- console.log(` - Create GitHub release: gh release create ${tagName} --title "Release ${tagName}"`);
465
- if (skipPublish) {
466
- console.log(` - Publish to npm: ${PKG_MANAGER} -r publish --access public --no-git-checks`);
999
+ finally {
1000
+ // WU-2062: Cleanup on failure never leave main dirty
1001
+ if (!releaseComplete && !dryRun) {
1002
+ if (!npmPublished) {
1003
+ // Nothing reached npm — safe to fully restore main
1004
+ await cleanupFailedRelease(packageDirs);
1005
+ }
1006
+ else {
1007
+ // npm packages are published but git commit/push failed.
1008
+ // Do NOT undo the version bump — the published packages reference this version.
1009
+ console.error(`\n${LOG_PREFIX} ⚠️ npm publish succeeded but git commit/push failed.`);
1010
+ console.error(`${LOG_PREFIX} The version bump is still in your working tree.`);
1011
+ console.error(`${LOG_PREFIX} To complete manually:`);
1012
+ console.error(`${LOG_PREFIX} git add -A && git commit -m "${buildCommitMessage(version)}"`);
1013
+ console.error(`${LOG_PREFIX} git tag -a ${buildTagName(version)} -m "Release ${buildTagName(version)}"`);
1014
+ console.error(`${LOG_PREFIX} LUMENFLOW_WU_TOOL=release git push origin main ${buildTagName(version)}`);
1015
+ }
467
1016
  }
468
- console.log(` - Verify packages: npm view @lumenflow/cli version`);
469
1017
  }
470
1018
  }
471
1019
  // Guard main() for testability