@wyxos/zephyr 0.5.0 → 0.7.4

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.
@@ -50,28 +50,58 @@ async function resolveSupportedLaravelTestCommand(rootDir, {runCommandCapture} =
50
50
  export async function resolveLocalDeploymentCheckSupport({
51
51
  rootDir,
52
52
  isLaravel,
53
+ skipTests = false,
54
+ skipLint = false,
53
55
  runCommandCapture
54
56
  } = {}) {
55
57
  let lintCommand = null
56
58
 
57
- try {
58
- lintCommand = await preflight.resolveSupportedLintCommand(rootDir, {commandExists})
59
- } catch (error) {
60
- if (error?.code !== 'ZEPHYR_LINT_COMMAND_NOT_FOUND') {
61
- throw error
59
+ if (!skipLint) {
60
+ try {
61
+ lintCommand = await preflight.resolveSupportedLintCommand(rootDir, {commandExists})
62
+ } catch (error) {
63
+ if (error?.code !== 'ZEPHYR_LINT_COMMAND_NOT_FOUND') {
64
+ throw error
65
+ }
62
66
  }
63
67
  }
64
68
 
65
- const testCommand = isLaravel
69
+ const buildCommand = isLaravel && !skipTests
70
+ ? await preflight.resolveSupportedBuildCommand(rootDir, {commandExists})
71
+ : null
72
+
73
+ const testCommand = isLaravel && !skipTests
66
74
  ? await resolveSupportedLaravelTestCommand(rootDir, {runCommandCapture})
67
75
  : null
68
76
 
69
77
  return {
70
78
  lintCommand,
79
+ buildCommand,
71
80
  testCommand
72
81
  }
73
82
  }
74
83
 
84
+ async function runLocalLaravelBuild(rootDir, {runCommand, logProcessing, logSuccess, buildCommand} = {}) {
85
+ try {
86
+ await preflight.runBuild(rootDir, {
87
+ runCommand,
88
+ logProcessing,
89
+ logSuccess,
90
+ commandExists,
91
+ buildCommand
92
+ })
93
+ } catch (error) {
94
+ if (error.code === 'ENOENT') {
95
+ throw new Error(
96
+ 'Failed to run local frontend build: npm executable not found.\n' +
97
+ 'Make sure npm is installed and available in your PATH.'
98
+ )
99
+ }
100
+
101
+ throw new Error(`Local frontend build failed. Fix build failures before deploying.\n${error.message}`)
102
+ }
103
+ }
104
+
75
105
  async function runLocalLaravelTests(rootDir, {runCommand, logProcessing, logSuccess, testCommand} = {}) {
76
106
  logProcessing?.('Running Laravel tests locally...')
77
107
 
@@ -95,19 +125,25 @@ export async function runLocalDeploymentChecks({
95
125
  isLaravel,
96
126
  hasHook,
97
127
  skipGitHooks = false,
128
+ skipTests = false,
129
+ skipLint = false,
130
+ forceRunWhenHookPresent = false,
98
131
  runCommand,
99
132
  runCommandCapture,
100
133
  logProcessing,
101
134
  logSuccess,
102
135
  logWarning,
103
136
  lintCommand = undefined,
137
+ buildCommand = undefined,
104
138
  testCommand = undefined
105
139
  } = {}) {
106
- const support = lintCommand !== undefined || testCommand !== undefined
107
- ? {lintCommand, testCommand}
140
+ const support = lintCommand !== undefined || buildCommand !== undefined || testCommand !== undefined
141
+ ? {lintCommand, buildCommand, testCommand}
108
142
  : await resolveLocalDeploymentCheckSupport({
109
143
  rootDir,
110
144
  isLaravel,
145
+ skipTests,
146
+ skipLint,
111
147
  runCommandCapture
112
148
  })
113
149
 
@@ -116,6 +152,10 @@ export async function runLocalDeploymentChecks({
116
152
  logWarning?.(
117
153
  'Pre-push git hook detected. Zephyr will run its built-in release checks manually because --skip-git-hooks is enabled, and the hook will be bypassed during git push.'
118
154
  )
155
+ } else if (forceRunWhenHookPresent) {
156
+ logProcessing?.(
157
+ 'Pre-push git hook detected. Zephyr will run its built-in release checks now before bumping the deployment version. The hook will still run again during git push.'
158
+ )
119
159
  } else {
120
160
  logProcessing?.(
121
161
  'Pre-push git hook detected. Built-in release checks are supported, but Zephyr will skip executing them here. If Zephyr pushes local commits during this release, the hook will run during git push.'
@@ -124,11 +164,19 @@ export async function runLocalDeploymentChecks({
124
164
  }
125
165
  }
126
166
 
127
- if (support.lintCommand === null) {
167
+ if (hasHook && !skipGitHooks && (skipLint || skipTests)) {
168
+ logWarning?.(
169
+ 'Pre-push git hook detected. --skip-lint/--skip-tests only skip Zephyr\'s built-in checks; your hook may still run its own checks during git push.'
170
+ )
171
+ }
172
+
173
+ if (skipLint) {
174
+ logWarning?.('Skipping lint because --skip-lint flag was provided.')
175
+ } else if (support.lintCommand === null) {
128
176
  logWarning?.('No supported lint command was found. Skipping linting checks.')
129
177
  }
130
178
 
131
- const lintRan = support.lintCommand === null
179
+ const lintRan = skipLint || support.lintCommand === null
132
180
  ? false
133
181
  : await preflight.runLinting(rootDir, {
134
182
  runCommand,
@@ -152,7 +200,18 @@ export async function runLocalDeploymentChecks({
152
200
  }
153
201
  }
154
202
 
155
- if (isLaravel) {
203
+ if (isLaravel && skipTests) {
204
+ logWarning?.('Skipping tests because --skip-tests flag was provided.')
205
+ } else if (isLaravel) {
206
+ if (support.buildCommand) {
207
+ await runLocalLaravelBuild(rootDir, {
208
+ runCommand,
209
+ logProcessing,
210
+ logSuccess,
211
+ buildCommand: support.buildCommand
212
+ })
213
+ }
214
+
156
215
  await runLocalLaravelTests(rootDir, {
157
216
  runCommand,
158
217
  logProcessing,
@@ -3,6 +3,7 @@ import {readFile} from 'node:fs/promises'
3
3
  import fs from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import process from 'node:process'
6
+ import semver from 'semver'
6
7
 
7
8
  import {writeStderr} from '../../utils/output.mjs'
8
9
  import {
@@ -20,6 +21,44 @@ async function readPackage(rootDir = process.cwd()) {
20
21
  return JSON.parse(raw)
21
22
  }
22
23
 
24
+ function ensureValidPackageVersion(pkg) {
25
+ const version = pkg?.version
26
+
27
+ if (!version) {
28
+ throw new Error('package.json does not have a version field. Add a valid semver version before using --skip-versioning.')
29
+ }
30
+
31
+ if (!semver.valid(version)) {
32
+ throw new Error(`Invalid current version "${version}" in package.json. Must be a valid semver.`)
33
+ }
34
+
35
+ return version
36
+ }
37
+
38
+ async function ensureReleaseTagMissing(version, rootDir = process.cwd(), {
39
+ runCommand = runReleaseCommand
40
+ } = {}) {
41
+ const tagName = `v${version}`
42
+ const {stdout} = await runCommand('git', ['tag', '-l', tagName], {capture: true, cwd: rootDir})
43
+
44
+ if (stdout.trim() === tagName) {
45
+ throw new Error(
46
+ `Release tag ${tagName} already exists. Remove the existing tag or update package.json before using --skip-versioning.`
47
+ )
48
+ }
49
+
50
+ return tagName
51
+ }
52
+
53
+ async function createReleaseTag(version, rootDir = process.cwd(), {
54
+ logStep,
55
+ runCommand = runReleaseCommand
56
+ } = {}) {
57
+ const tagName = `v${version}`
58
+ logStep?.(`Creating release tag ${tagName}...`)
59
+ await runCommand('git', ['tag', '-a', tagName, '-m', tagName], {capture: true, cwd: rootDir})
60
+ }
61
+
23
62
  function hasScript(pkg, scriptName) {
24
63
  return pkg?.scripts?.[scriptName] !== undefined
25
64
  }
@@ -354,6 +393,7 @@ export async function releaseNodePackage({
354
393
  skipGitHooks = false,
355
394
  skipTests = false,
356
395
  skipLint = false,
396
+ skipVersioning = false,
357
397
  skipBuild = false,
358
398
  skipDeploy = false,
359
399
  rootDir = process.cwd(),
@@ -373,6 +413,7 @@ export async function releaseNodePackage({
373
413
 
374
414
  logStep?.('Reading package metadata...')
375
415
  const pkg = await readPackage(rootDir)
416
+ const currentVersion = skipVersioning ? ensureValidPackageVersion(pkg) : null
376
417
 
377
418
  logStep?.('Validating dependencies...')
378
419
  await validateReleaseDependencies(rootDir, {
@@ -393,24 +434,39 @@ export async function releaseNodePackage({
393
434
  skipGitHooks
394
435
  })
395
436
  await ensureReleaseBranchReady({rootDir, branchMethod: 'show-current', logStep, logWarning})
396
- const resolvedReleaseType = await resolveReleaseType({
397
- releaseType,
398
- currentVersion: pkg.version,
399
- packageName: pkg.name,
400
- rootDir,
401
- interactive,
402
- runPrompt,
403
- runCommand,
404
- logStep,
405
- logWarning
406
- })
437
+
438
+ if (skipVersioning) {
439
+ await ensureReleaseTagMissing(currentVersion, rootDir, {runCommand})
440
+ logWarning?.(
441
+ `Skipping package.json version update because --skip-versioning flag was provided. Releasing current version ${currentVersion}.`
442
+ )
443
+ }
444
+
445
+ const resolvedReleaseType = skipVersioning
446
+ ? null
447
+ : await resolveReleaseType({
448
+ releaseType,
449
+ currentVersion: pkg.version,
450
+ packageName: pkg.name,
451
+ rootDir,
452
+ interactive,
453
+ runPrompt,
454
+ runCommand,
455
+ logStep,
456
+ logWarning
457
+ })
407
458
 
408
459
  await runLint(skipLint, pkg, rootDir, {logStep, logSuccess, logWarning, runCommand})
409
460
  await runTests(skipTests, pkg, rootDir, {logStep, logSuccess, logWarning, runCommand})
410
461
  await runLibBuild(skipBuild, pkg, rootDir, {logStep, logSuccess, logWarning, runCommand, skipGitHooks})
411
462
 
412
- const updatedPkg = await bumpVersion(resolvedReleaseType, rootDir, {logStep, logSuccess, runCommand, skipGitHooks})
463
+ const updatedPkg = skipVersioning
464
+ ? {...pkg, version: currentVersion}
465
+ : await bumpVersion(resolvedReleaseType, rootDir, {logStep, logSuccess, runCommand, skipGitHooks})
413
466
  await runBuild(skipBuild, updatedPkg, rootDir, {logStep, logSuccess, logWarning, runCommand})
467
+ if (skipVersioning) {
468
+ await createReleaseTag(updatedPkg.version, rootDir, {logStep, runCommand})
469
+ }
414
470
  await pushChanges(rootDir, {logStep, logSuccess, runCommand, skipGitHooks})
415
471
  await deployGHPages(skipDeploy, updatedPkg, rootDir, {logStep, logSuccess, logWarning, runCommand, skipGitHooks})
416
472
 
@@ -20,6 +20,30 @@ async function readComposer(rootDir = process.cwd()) {
20
20
  return JSON.parse(raw)
21
21
  }
22
22
 
23
+ async function ensureReleaseTagMissing(version, rootDir = process.cwd(), {
24
+ runCommand = runReleaseCommand
25
+ } = {}) {
26
+ const tagName = `v${version}`
27
+ const {stdout} = await runCommand('git', ['tag', '-l', tagName], {capture: true, cwd: rootDir})
28
+
29
+ if (stdout.trim() === tagName) {
30
+ throw new Error(
31
+ `Release tag ${tagName} already exists. Remove the existing tag or update composer.json before using --skip-versioning.`
32
+ )
33
+ }
34
+
35
+ return tagName
36
+ }
37
+
38
+ async function createReleaseTag(version, rootDir = process.cwd(), {
39
+ logStep,
40
+ runCommand = runReleaseCommand
41
+ } = {}) {
42
+ const tagName = `v${version}`
43
+ logStep?.(`Creating git tag ${tagName}...`)
44
+ await runCommand('git', ['tag', tagName], {cwd: rootDir})
45
+ }
46
+
23
47
  async function writeComposer(rootDir, composer, composerPath = null) {
24
48
  const pathToUse = composerPath || join(rootDir, 'composer.json')
25
49
  const content = JSON.stringify(composer, null, 2) + '\n'
@@ -218,6 +242,7 @@ export async function releasePackagistPackage({
218
242
  skipGitHooks = false,
219
243
  skipTests = false,
220
244
  skipLint = false,
245
+ skipVersioning = false,
221
246
  rootDir = process.cwd(),
222
247
  logStep,
223
248
  logSuccess,
@@ -241,6 +266,10 @@ export async function releasePackagistPackage({
241
266
  throw new Error('composer.json does not have a version field. Add "version": "0.0.0" to composer.json.')
242
267
  }
243
268
 
269
+ if (skipVersioning && !semver.valid(composer.version)) {
270
+ throw new Error(`Invalid current version "${composer.version}" in composer.json. Must be a valid semver.`)
271
+ }
272
+
244
273
  logStep?.('Validating dependencies...')
245
274
  await validateReleaseDependencies(rootDir, {
246
275
  prompt: runPrompt,
@@ -260,22 +289,37 @@ export async function releasePackagistPackage({
260
289
  skipGitHooks
261
290
  })
262
291
  await ensureReleaseBranchReady({rootDir, branchMethod: 'show-current', logStep, logWarning})
263
- const resolvedReleaseType = await resolveReleaseType({
264
- releaseType,
265
- currentVersion: composer.version,
266
- packageName: composer.name,
267
- rootDir,
268
- interactive,
269
- runPrompt,
270
- runCommand,
271
- logStep,
272
- logWarning
273
- })
292
+
293
+ if (skipVersioning) {
294
+ await ensureReleaseTagMissing(composer.version, rootDir, {runCommand})
295
+ logWarning?.(
296
+ `Skipping composer.json version update because --skip-versioning flag was provided. Releasing current version ${composer.version}.`
297
+ )
298
+ }
299
+
300
+ const resolvedReleaseType = skipVersioning
301
+ ? null
302
+ : await resolveReleaseType({
303
+ releaseType,
304
+ currentVersion: composer.version,
305
+ packageName: composer.name,
306
+ rootDir,
307
+ interactive,
308
+ runPrompt,
309
+ runCommand,
310
+ logStep,
311
+ logWarning
312
+ })
274
313
 
275
314
  await runLint(skipLint, rootDir, {logStep, logSuccess, logWarning, runCommand, progressWriter})
276
315
  await runTests(skipTests, composer, rootDir, {logStep, logSuccess, logWarning, runCommand, progressWriter})
277
316
 
278
- const updatedComposer = await bumpVersion(resolvedReleaseType, rootDir, {logStep, logSuccess, runCommand, skipGitHooks})
317
+ const updatedComposer = skipVersioning
318
+ ? {...composer}
319
+ : await bumpVersion(resolvedReleaseType, rootDir, {logStep, logSuccess, runCommand, skipGitHooks})
320
+ if (skipVersioning) {
321
+ await createReleaseTag(updatedComposer.version, rootDir, {logStep, runCommand})
322
+ }
279
323
  await pushChanges(rootDir, {logStep, logSuccess, runCommand, skipGitHooks})
280
324
 
281
325
  logSuccess?.(`Release workflow completed for ${composer.name}@${updatedComposer.version}.`)
@@ -5,6 +5,11 @@ import {Command} from 'commander'
5
5
  import {InvalidCliOptionsError} from '../runtime/errors.mjs'
6
6
 
7
7
  const WORKFLOW_TYPES = new Set(['node', 'vue', 'packagist'])
8
+
9
+ function hasFlag(args = [], flag) {
10
+ return args.some((arg) => arg === flag || arg.startsWith(`${flag}=`))
11
+ }
12
+
8
13
  function normalizeMaintenanceMode(value) {
9
14
  if (value == null) {
10
15
  return null
@@ -35,9 +40,12 @@ export function parseCliOptions(args = process.argv.slice(2)) {
35
40
  .option('--resume-pending', 'Resume a saved pending deployment snapshot without prompting.')
36
41
  .option('--discard-pending', 'Discard a saved pending deployment snapshot without prompting.')
37
42
  .option('--maintenance <mode>', 'Laravel maintenance mode policy for app deployments (on|off).')
43
+ .option('--auto-commit', 'Automatically commit dirty deploy changes with a Codex-generated message.')
44
+ .option('--skip-versioning', 'Skip updating package/composer version files before continuing.')
38
45
  .option('--skip-git-hooks', 'Bypass local git hooks for any commits and pushes Zephyr performs.')
39
- .option('--skip-tests', 'Skip test execution in package release workflows.')
40
- .option('--skip-lint', 'Skip lint execution in package release workflows.')
46
+ .option('--skip-checks', 'Skip Zephyr local lint and test execution.')
47
+ .option('--skip-tests', 'Skip Zephyr local test execution in package release and app deployment workflows.')
48
+ .option('--skip-lint', 'Skip Zephyr local lint execution in package release and app deployment workflows.')
41
49
  .option('--skip-build', 'Skip build execution in node/vue release workflows.')
42
50
  .option('--skip-deploy', 'Skip GitHub Pages deployment in node/vue release workflows.')
43
51
  .argument(
@@ -53,6 +61,7 @@ export function parseCliOptions(args = process.argv.slice(2)) {
53
61
 
54
62
  const options = program.opts()
55
63
  const workflowType = options.type ?? null
64
+ const explicitSkipChecks = hasFlag(args, '--skip-checks')
56
65
 
57
66
  if (workflowType && !WORKFLOW_TYPES.has(workflowType)) {
58
67
  throw new InvalidCliOptionsError('Invalid value for --type. Use one of: node, vue, packagist.')
@@ -67,11 +76,21 @@ export function parseCliOptions(args = process.argv.slice(2)) {
67
76
  resumePending: Boolean(options.resumePending),
68
77
  discardPending: Boolean(options.discardPending),
69
78
  maintenanceMode: normalizeMaintenanceMode(options.maintenance),
79
+ autoCommit: Boolean(options.autoCommit),
80
+ skipVersioning: Boolean(options.skipVersioning),
70
81
  skipGitHooks: Boolean(options.skipGitHooks),
71
- skipTests: Boolean(options.skipTests),
72
- skipLint: Boolean(options.skipLint),
82
+ skipChecks: Boolean(options.skipChecks),
83
+ skipTests: Boolean(options.skipTests || options.skipChecks),
84
+ skipLint: Boolean(options.skipLint || options.skipChecks),
73
85
  skipBuild: Boolean(options.skipBuild),
74
- skipDeploy: Boolean(options.skipDeploy)
86
+ skipDeploy: Boolean(options.skipDeploy),
87
+ explicitMaintenanceMode: hasFlag(args, '--maintenance'),
88
+ explicitAutoCommit: hasFlag(args, '--auto-commit'),
89
+ explicitSkipVersioning: hasFlag(args, '--skip-versioning'),
90
+ explicitSkipGitHooks: hasFlag(args, '--skip-git-hooks'),
91
+ explicitSkipChecks,
92
+ explicitSkipTests: hasFlag(args, '--skip-tests') || explicitSkipChecks,
93
+ explicitSkipLint: hasFlag(args, '--skip-lint') || explicitSkipChecks
75
94
  }
76
95
  }
77
96
 
@@ -84,8 +103,8 @@ export function validateCliOptions(options = {}) {
84
103
  resumePending = false,
85
104
  discardPending = false,
86
105
  maintenanceMode = null,
87
- skipTests = false,
88
- skipLint = false,
106
+ autoCommit = false,
107
+ skipVersioning = false,
89
108
  skipBuild = false,
90
109
  skipDeploy = false
91
110
  } = options
@@ -112,13 +131,21 @@ export function validateCliOptions(options = {}) {
112
131
  if (maintenanceMode !== null) {
113
132
  throw new InvalidCliOptionsError('--maintenance is only valid for app deployments.')
114
133
  }
134
+
135
+ if (autoCommit) {
136
+ throw new InvalidCliOptionsError('--auto-commit is only valid for app deployments.')
137
+ }
115
138
  } else {
116
- if (skipTests || skipLint || skipBuild || skipDeploy) {
117
- throw new InvalidCliOptionsError('Release-only skip flags are not valid for app deployments.')
139
+ if (skipBuild || skipDeploy) {
140
+ throw new InvalidCliOptionsError('--skip-build and --skip-deploy are only valid for node/vue release workflows.')
118
141
  }
119
142
 
120
143
  if (nonInteractive && !presetName) {
121
144
  throw new InvalidCliOptionsError('--non-interactive app deployments require --preset <name>.')
122
145
  }
123
146
  }
147
+
148
+ if (skipVersioning && options.versionArg) {
149
+ throw new InvalidCliOptionsError('--skip-versioning cannot be used together with an explicit version or bump argument.')
150
+ }
124
151
  }
@@ -0,0 +1,89 @@
1
+ export const DEFAULT_PRESET_OPTIONS = Object.freeze({
2
+ maintenanceMode: null,
3
+ skipGitHooks: false,
4
+ skipTests: false,
5
+ skipLint: false,
6
+ skipVersioning: false,
7
+ autoCommit: false
8
+ })
9
+
10
+ function normalizeBoolean(value, fallback = false) {
11
+ return typeof value === 'boolean' ? value : fallback
12
+ }
13
+
14
+ function normalizeMaintenanceMode(value) {
15
+ return typeof value === 'boolean' ? value : null
16
+ }
17
+
18
+ export function normalizePresetOptions(options = {}) {
19
+ return {
20
+ maintenanceMode: normalizeMaintenanceMode(options?.maintenanceMode),
21
+ skipGitHooks: normalizeBoolean(options?.skipGitHooks),
22
+ skipTests: normalizeBoolean(options?.skipTests),
23
+ skipLint: normalizeBoolean(options?.skipLint),
24
+ skipVersioning: normalizeBoolean(options?.skipVersioning),
25
+ autoCommit: normalizeBoolean(options?.autoCommit)
26
+ }
27
+ }
28
+
29
+ export function mergeDeployOptions(executionMode = {}, presetOptions = {}) {
30
+ const normalizedPresetOptions = normalizePresetOptions(presetOptions)
31
+
32
+ return {
33
+ maintenanceMode: executionMode.explicitMaintenanceMode === true
34
+ ? executionMode.maintenanceMode
35
+ : normalizedPresetOptions.maintenanceMode,
36
+ skipGitHooks: executionMode.explicitSkipGitHooks === true
37
+ ? executionMode.skipGitHooks === true
38
+ : normalizedPresetOptions.skipGitHooks,
39
+ skipTests: executionMode.explicitSkipTests === true
40
+ ? executionMode.skipTests === true
41
+ : normalizedPresetOptions.skipTests,
42
+ skipLint: executionMode.explicitSkipLint === true
43
+ ? executionMode.skipLint === true
44
+ : normalizedPresetOptions.skipLint,
45
+ skipVersioning: executionMode.explicitSkipVersioning === true
46
+ ? executionMode.skipVersioning === true
47
+ : normalizedPresetOptions.skipVersioning,
48
+ autoCommit: executionMode.explicitAutoCommit === true
49
+ ? executionMode.autoCommit === true
50
+ : normalizedPresetOptions.autoCommit
51
+ }
52
+ }
53
+
54
+ export function buildPresetOptionsFromExecutionMode(executionMode = {}, existingOptions = {}) {
55
+ const normalizedOptions = normalizePresetOptions(existingOptions)
56
+
57
+ if (executionMode.explicitMaintenanceMode === true) {
58
+ normalizedOptions.maintenanceMode = executionMode.maintenanceMode
59
+ }
60
+
61
+ if (executionMode.explicitSkipGitHooks === true) {
62
+ normalizedOptions.skipGitHooks = executionMode.skipGitHooks === true
63
+ }
64
+
65
+ if (executionMode.explicitSkipTests === true) {
66
+ normalizedOptions.skipTests = executionMode.skipTests === true
67
+ }
68
+
69
+ if (executionMode.explicitSkipLint === true) {
70
+ normalizedOptions.skipLint = executionMode.skipLint === true
71
+ }
72
+
73
+ if (executionMode.explicitSkipVersioning === true) {
74
+ normalizedOptions.skipVersioning = executionMode.skipVersioning === true
75
+ }
76
+
77
+ if (executionMode.explicitAutoCommit === true) {
78
+ normalizedOptions.autoCommit = executionMode.autoCommit === true
79
+ }
80
+
81
+ return normalizedOptions
82
+ }
83
+
84
+ export function presetOptionsEqual(left = {}, right = {}) {
85
+ const normalizedLeft = normalizePresetOptions(left)
86
+ const normalizedRight = normalizePresetOptions(right)
87
+
88
+ return JSON.stringify(normalizedLeft) === JSON.stringify(normalizedRight)
89
+ }
@@ -1,6 +1,10 @@
1
1
  import fs from 'node:fs/promises'
2
2
 
3
3
  import {ZephyrError} from '../runtime/errors.mjs'
4
+ import {
5
+ normalizePresetOptions,
6
+ presetOptionsEqual
7
+ } from './preset-options.mjs'
4
8
  import { ensureDirectory, getProjectConfigDir, getProjectConfigPath } from '../utils/paths.mjs'
5
9
  import { generateId } from '../utils/id.mjs'
6
10
 
@@ -44,27 +48,58 @@ export function migratePresets(presets, apps) {
44
48
  return { presets: [], needsMigration: false }
45
49
  }
46
50
 
47
- const keyToAppId = new Map()
51
+ const appLookup = new Map()
48
52
  apps.forEach((app) => {
49
53
  if (app.id && app.serverName && app.projectPath) {
50
54
  const key = `${app.serverName}:${app.projectPath}`
51
- keyToAppId.set(key, app.id)
55
+ appLookup.set(key, app)
52
56
  }
53
57
  })
54
58
 
55
59
  let needsMigration = false
56
- const migrated = presets.map((preset) => {
57
- const updated = { ...preset }
60
+ const migrated = presets.flatMap((preset) => {
61
+ if (!preset || typeof preset !== 'object') {
62
+ needsMigration = true
63
+ return []
64
+ }
58
65
 
59
- if (preset.key && !preset.appId) {
60
- const appId = keyToAppId.get(preset.key)
61
- if (appId) {
62
- needsMigration = true
63
- updated.appId = appId
66
+ const updated = {
67
+ name: typeof preset.name === 'string' ? preset.name : '',
68
+ appId: typeof preset.appId === 'string' ? preset.appId : null,
69
+ branch: typeof preset.branch === 'string' ? preset.branch : null,
70
+ options: normalizePresetOptions(preset.options)
71
+ }
72
+
73
+ if (!presetOptionsEqual(updated.options, preset.options)) {
74
+ needsMigration = true
75
+ }
76
+
77
+ if (preset.key) {
78
+ needsMigration = true
79
+ const [serverName = null, projectPath = null, legacyBranch = null] = String(preset.key).split(':')
80
+ const app = serverName && projectPath
81
+ ? appLookup.get(`${serverName}:${projectPath}`)
82
+ : null
83
+
84
+ if (app?.id) {
85
+ updated.appId = app.id
86
+ }
87
+
88
+ if (!updated.branch && legacyBranch) {
89
+ updated.branch = legacyBranch
64
90
  }
65
91
  }
66
92
 
67
- return updated
93
+ if (!updated.name) {
94
+ needsMigration = true
95
+ return []
96
+ }
97
+
98
+ if (!preset.appId || preset.key || preset.branch !== updated.branch) {
99
+ needsMigration = true
100
+ }
101
+
102
+ return [updated]
68
103
  })
69
104
 
70
105
  return { presets: migrated, needsMigration }