@treeseed/sdk 0.4.6 → 0.4.8

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.
@@ -1,23 +1,21 @@
1
- import { readFileSync, writeFileSync, existsSync, copyFileSync } from "node:fs";
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { spawn, spawnSync } from "node:child_process";
4
4
  import {
5
5
  applyTreeseedEnvironmentToProcess,
6
+ applyTreeseedConfigValues,
7
+ applyTreeseedSafeRepairs,
6
8
  assertTreeseedCommandEnvironment,
7
9
  checkTreeseedProviderConnections,
8
- createDefaultTreeseedMachineConfig,
10
+ collectTreeseedConfigContext,
11
+ collectTreeseedPrintEnvReport,
9
12
  ensureTreeseedActVerificationTooling,
10
13
  ensureTreeseedGitignoreEntries,
11
- formatTreeseedConfigEnvironmentReport,
12
- formatTreeseedProviderConnectionReport,
14
+ finalizeTreeseedConfig,
13
15
  getTreeseedMachineConfigPaths,
14
- loadTreeseedMachineConfig,
15
- resolveTreeseedMachineEnvironmentValues,
16
- rotateTreeseedMachineKey,
17
- runTreeseedConfigWizard,
18
- writeTreeseedLocalEnvironmentFiles,
19
- writeTreeseedMachineConfig
16
+ rotateTreeseedMachineKey
20
17
  } from "../operations/services/config-runtime.js";
18
+ import { exportTreeseedCodebase } from "../operations/services/export-runtime.js";
21
19
  import {
22
20
  assertDeploymentInitialized,
23
21
  cleanupDestroyedState,
@@ -73,6 +71,10 @@ import {
73
71
  } from "../operations/services/workspace-save.js";
74
72
  import { run, workspaceRoot } from "../operations/services/workspace-tools.js";
75
73
  import { resolveTreeseedWorkflowState } from "../workflow-state.js";
74
+ import {
75
+ classifyTreeseedBranchRole,
76
+ resolveTreeseedWorkflowPaths
77
+ } from "./policy.js";
76
78
  class TreeseedWorkflowError extends Error {
77
79
  code;
78
80
  operation;
@@ -125,17 +127,6 @@ function withContextEnv(env, action) {
125
127
  }
126
128
  }
127
129
  }
128
- function runChild(command, args, context, cwd, label) {
129
- const result = spawnSync(command, args, {
130
- cwd,
131
- env: { ...process.env, ...context.env ?? {} },
132
- stdio: "inherit"
133
- });
134
- if (result.status !== 0) {
135
- workflowError("dev", "unsupported_state", `${label} failed.`, { exitCode: result.status ?? 1 });
136
- }
137
- return result;
138
- }
139
130
  function runNodeScript(scriptName, context, cwd, label) {
140
131
  const result = spawnSync(process.execPath, [packageScriptPath(scriptName)], {
141
132
  cwd,
@@ -157,50 +148,51 @@ function normalizeConfigScopes(input) {
157
148
  }
158
149
  return ["local", "staging", "prod"].filter((scope) => requested.includes(scope));
159
150
  }
160
- function dedupeRepairActions(actions) {
161
- const seen = /* @__PURE__ */ new Set();
162
- return actions.filter((action) => {
163
- if (seen.has(action.id)) return false;
164
- seen.add(action.id);
165
- return true;
166
- });
151
+ function resolveWorkflowStateSnapshot(cwd) {
152
+ return resolveTreeseedWorkflowState(cwd);
167
153
  }
168
- function applyTreeseedSafeRepairs(tenantRoot) {
169
- const actions = [];
170
- ensureTreeseedGitignoreEntries(tenantRoot);
171
- actions.push({ id: "gitignore", detail: "Ensured Treeseed gitignore entries are present." });
172
- const envLocalPath = resolve(tenantRoot, ".env.local");
173
- const envLocalExamplePath = resolve(tenantRoot, ".env.local.example");
174
- if (!existsSync(envLocalPath) && existsSync(envLocalExamplePath)) {
175
- copyFileSync(envLocalExamplePath, envLocalPath);
176
- actions.push({ id: "env-local", detail: "Created .env.local from .env.local.example." });
177
- }
178
- const deployConfig = loadCliDeployConfig(tenantRoot);
179
- const { configPath } = getTreeseedMachineConfigPaths(tenantRoot);
180
- if (!existsSync(configPath)) {
181
- const machineConfig2 = createDefaultTreeseedMachineConfig({
182
- tenantRoot,
183
- deployConfig,
184
- tenantConfig: void 0
185
- });
186
- writeTreeseedMachineConfig(tenantRoot, machineConfig2);
187
- actions.push({ id: "machine-config", detail: "Created the default Treeseed machine config." });
154
+ function resolveProjectRootOrThrow(operation, cwd) {
155
+ const resolved = resolveTreeseedWorkflowPaths(cwd);
156
+ if (!resolved.tenantRoot) {
157
+ workflowError(operation, "validation_failed", `Treeseed ${operation} requires a Treeseed project. Run the command from inside a tenant or initialize one first.`);
188
158
  }
189
- resolveTreeseedMachineEnvironmentValues(tenantRoot, "local");
190
- actions.push({ id: "machine-key", detail: "Ensured the Treeseed machine key exists." });
191
- const machineConfig = loadTreeseedMachineConfig(tenantRoot);
192
- writeTreeseedMachineConfig(tenantRoot, machineConfig);
193
- writeTreeseedLocalEnvironmentFiles(tenantRoot);
194
- actions.push({ id: "local-env", detail: "Regenerated .env.local and .dev.vars from the current machine config." });
195
- for (const scope of ["local", "staging", "prod"]) {
196
- const target = createPersistentDeployTarget(scope);
197
- const state = loadDeployState(tenantRoot, deployConfig, { target });
198
- if (state.readiness?.initialized || scope === "local") {
199
- ensureGeneratedWranglerConfig(tenantRoot, { target });
200
- actions.push({ id: `wrangler-${scope}`, detail: `Regenerated the ${scope} generated Wrangler config.` });
201
- }
159
+ return resolved.cwd;
160
+ }
161
+ function resolveRepoState(repoDir) {
162
+ const branchName = currentBranch(repoDir) || null;
163
+ return {
164
+ repoDir,
165
+ branchName,
166
+ branchRole: classifyTreeseedBranchRole(branchName, repoDir),
167
+ dirtyWorktree: gitStatusPorcelain(repoDir).length > 0
168
+ };
169
+ }
170
+ function buildWorkflowResult(operation, cwd, payload, nextSteps) {
171
+ return {
172
+ ok: true,
173
+ operation,
174
+ payload: {
175
+ ...payload,
176
+ finalState: resolveWorkflowStateSnapshot(cwd)
177
+ },
178
+ nextSteps
179
+ };
180
+ }
181
+ function ensureLocalReadinessOrThrow(operation, tenantRoot) {
182
+ const state = resolveWorkflowStateSnapshot(tenantRoot);
183
+ if (!state.readiness.local.ready) {
184
+ workflowError(
185
+ operation,
186
+ "validation_failed",
187
+ [
188
+ `Treeseed ${operation} requires the local environment to be configured.`,
189
+ ...state.readiness.local.blockers,
190
+ "Run `treeseed config --environment local` first."
191
+ ].join("\n"),
192
+ { details: { readiness: state.readiness.local } }
193
+ );
202
194
  }
203
- return dedupeRepairActions(actions);
195
+ return state;
204
196
  }
205
197
  function bumpRootPackageJson(root, level) {
206
198
  const packageJsonPath = resolve(root, "package.json");
@@ -342,6 +334,56 @@ function resolveDestroyConfirmation(context, expected, input) {
342
334
  }
343
335
  return false;
344
336
  }
337
+ function syncCurrentBranchToOrigin(operation, repoDir, branch) {
338
+ try {
339
+ if (remoteBranchExists(repoDir, branch)) {
340
+ run("git", ["pull", "--rebase", "origin", branch], { cwd: repoDir });
341
+ run("git", ["push", "origin", branch], { cwd: repoDir });
342
+ return {
343
+ remoteBranchExisted: true,
344
+ pulledRebase: true,
345
+ pushed: true,
346
+ createdRemoteBranch: false,
347
+ conflicts: false
348
+ };
349
+ }
350
+ run("git", ["push", "-u", "origin", branch], { cwd: repoDir });
351
+ return {
352
+ remoteBranchExisted: false,
353
+ pulledRebase: false,
354
+ pushed: true,
355
+ createdRemoteBranch: true,
356
+ conflicts: false
357
+ };
358
+ } catch {
359
+ const report = collectMergeConflictReport(repoDir);
360
+ throw new TreeseedWorkflowError(operation, "merge_conflict", formatMergeConflictReport(report, repoDir, branch), {
361
+ details: { branch, report },
362
+ exitCode: 12
363
+ });
364
+ }
365
+ }
366
+ async function maybeAutoSaveCurrentTaskBranch(helpers, operation, input) {
367
+ const tenantRoot = resolveProjectRootOrThrow(operation, helpers.cwd());
368
+ const repoDir = gitWorkflowRoot(tenantRoot);
369
+ const before = resolveRepoState(repoDir);
370
+ if (!before.dirtyWorktree) {
371
+ return { performed: false, save: null };
372
+ }
373
+ if (input.autoSave === false) {
374
+ workflowError(operation, "validation_failed", `Treeseed ${operation} requires a clean worktree or autoSave enabled.`);
375
+ }
376
+ const saveResult = await workflowSave(helpers, {
377
+ message: operation === "close" ? `close: ${input.message}` : input.message,
378
+ verify: input.verify === true,
379
+ refreshPreview: false,
380
+ preview: input.preview
381
+ });
382
+ return {
383
+ performed: true,
384
+ save: saveResult.payload
385
+ };
386
+ }
345
387
  async function workflowStatus(helpers) {
346
388
  return withContextEnv(helpers.context.env, () => createStatusResult(helpers.cwd()));
347
389
  }
@@ -351,7 +393,7 @@ async function workflowTasks(helpers) {
351
393
  async function workflowConfig(helpers, input = {}) {
352
394
  try {
353
395
  return await withContextEnv(helpers.context.env, async () => {
354
- const tenantRoot = helpers.cwd();
396
+ const tenantRoot = resolveProjectRootOrThrow("config", helpers.cwd());
355
397
  const scopes = normalizeConfigScopes(input);
356
398
  const sync = input.syncProviders ?? input.sync ?? "all";
357
399
  const printEnv = input.printEnv === true;
@@ -367,23 +409,22 @@ async function workflowConfig(helpers, input = {}) {
367
409
  });
368
410
  ensureTreeseedGitignoreEntries(tenantRoot);
369
411
  const preflight = collectCliPreflight({ cwd: tenantRoot, requireAuth: false });
412
+ const contextSnapshot = collectTreeseedConfigContext({
413
+ tenantRoot,
414
+ scopes,
415
+ env: helpers.context.env
416
+ });
370
417
  if (printEnvOnly) {
371
- const reports = scopes.map((scope) => ({
418
+ const reports2 = scopes.map((scope) => ({
372
419
  scope,
373
- environmentReport: formatTreeseedConfigEnvironmentReport({
420
+ environment: collectTreeseedPrintEnvReport({
374
421
  tenantRoot,
375
422
  scope,
376
423
  env: helpers.context.env,
377
424
  revealSecrets
378
425
  }),
379
- providerReport: formatTreeseedProviderConnectionReport(
380
- checkTreeseedProviderConnections({ tenantRoot, scope, env: helpers.context.env })
381
- )
426
+ provider: checkTreeseedProviderConnections({ tenantRoot, scope, env: helpers.context.env })
382
427
  }));
383
- for (const report of reports) {
384
- maybePrint(helpers.write, report.environmentReport);
385
- maybePrint(helpers.write, report.providerReport);
386
- }
387
428
  return {
388
429
  ok: true,
389
430
  operation: "config",
@@ -392,7 +433,7 @@ async function workflowConfig(helpers, input = {}) {
392
433
  scopes,
393
434
  sync,
394
435
  secretsRevealed: revealSecrets,
395
- reports,
436
+ reports: reports2,
396
437
  repairs,
397
438
  preflight,
398
439
  toolHealth
@@ -421,31 +462,46 @@ async function workflowConfig(helpers, input = {}) {
421
462
  ])
422
463
  };
423
464
  }
424
- const wizardResult = await runTreeseedConfigWizard({
465
+ const explicitUpdates = Array.isArray(input.updates) ? input.updates.map((update) => ({
466
+ scope: update.scope,
467
+ entryId: String(update.entryId ?? ""),
468
+ value: typeof update.value === "string" ? update.value : "",
469
+ reused: update.reused === true
470
+ })) : null;
471
+ const autoUpdates = scopes.flatMap(
472
+ (scope) => contextSnapshot.entriesByScope[scope].map((entry) => ({
473
+ scope,
474
+ entryId: entry.id,
475
+ value: entry.effectiveValue,
476
+ reused: entry.currentValue.length > 0 || entry.suggestedValue.length > 0
477
+ }))
478
+ );
479
+ const applyResult = applyTreeseedConfigValues({
480
+ tenantRoot,
481
+ updates: explicitUpdates ?? autoUpdates
482
+ });
483
+ const finalizeResult = finalizeTreeseedConfig({
425
484
  tenantRoot,
426
485
  scopes,
427
486
  sync,
428
- authStatus: preflight.checks.auth,
429
- env: helpers.context.env,
430
- useInk: input.nonInteractive === true ? false : process.stdin.isTTY && process.stdout.isTTY,
431
- printEnv,
432
- revealSecrets,
433
- write: (line) => maybePrint(helpers.write, line),
434
- prompt: async (message) => {
435
- if (input.nonInteractive === true) {
436
- return "";
437
- }
438
- return String(await (helpers.context.prompt?.(message) ?? ""));
439
- }
487
+ env: helpers.context.env
440
488
  });
441
- writeTreeseedLocalEnvironmentFiles(tenantRoot);
442
- applyTreeseedEnvironmentToProcess({ tenantRoot, scope: "local", override: true });
489
+ const reports = printEnv ? scopes.map((scope) => ({
490
+ scope,
491
+ environment: collectTreeseedPrintEnvReport({
492
+ tenantRoot,
493
+ scope,
494
+ env: helpers.context.env,
495
+ revealSecrets
496
+ }),
497
+ provider: finalizeResult.connectionChecks.find((report) => report.scope === scope) ?? checkTreeseedProviderConnections({ tenantRoot, scope, env: helpers.context.env })
498
+ })) : [];
443
499
  const { configPath, keyPath } = getTreeseedMachineConfigPaths(tenantRoot);
444
500
  const state = resolveTreeseedWorkflowState(tenantRoot);
445
- return {
446
- ok: true,
447
- operation: "config",
448
- payload: {
501
+ return buildWorkflowResult(
502
+ "config",
503
+ tenantRoot,
504
+ {
449
505
  mode: "configure",
450
506
  scopes,
451
507
  sync,
@@ -454,24 +510,37 @@ async function workflowConfig(helpers, input = {}) {
454
510
  repairs,
455
511
  preflight,
456
512
  toolHealth,
457
- result: wizardResult,
458
- state
513
+ context: contextSnapshot,
514
+ result: {
515
+ ...applyResult,
516
+ ...finalizeResult
517
+ },
518
+ reports,
519
+ state,
520
+ readiness: state.readiness
459
521
  },
460
- nextSteps: createNextSteps([
522
+ createNextSteps([
461
523
  ...scopes.includes("local") ? [{ operation: "dev", reason: "Start the local Treeseed runtime on the initialized local environment." }] : [],
462
524
  ...scopes.includes("staging") ? [{ operation: "status", reason: "Confirm staging readiness after initializing shared services." }] : [],
463
525
  { operation: "switch", reason: "Create or resume a task branch once the runtime foundation is ready.", input: { branch: "feature/my-change", preview: true } }
464
526
  ])
465
- };
527
+ );
466
528
  });
467
529
  } catch (error) {
468
530
  toError("config", error);
469
531
  }
470
532
  }
533
+ async function workflowExport(helpers, input = {}) {
534
+ return await withContextEnv(helpers.context.env, async () => {
535
+ const directory = resolve(helpers.context.cwd ?? helpers.cwd(), input.directory ?? ".");
536
+ const exported = await exportTreeseedCodebase({ directory });
537
+ return buildWorkflowResult("export", exported.tenantRoot, exported);
538
+ });
539
+ }
471
540
  async function workflowSwitch(helpers, input) {
472
541
  try {
473
542
  return withContextEnv(helpers.context.env, () => {
474
- const tenantRoot = helpers.cwd();
543
+ const tenantRoot = resolveProjectRootOrThrow("switch", helpers.cwd());
475
544
  const branchName = String(input.branch ?? input.branchName ?? "").trim();
476
545
  if (!branchName) {
477
546
  workflowError("switch", "validation_failed", "Treeseed switch requires a branch name.");
@@ -503,10 +572,10 @@ async function workflowSwitch(helpers, input) {
503
572
  previewResult = deployBranchPreview(tenantRoot, branchName, helpers.context, { initialize: true });
504
573
  }
505
574
  const state = resolveTreeseedWorkflowState(tenantRoot);
506
- return {
507
- ok: true,
508
- operation: "switch",
509
- payload: {
575
+ return buildWorkflowResult(
576
+ "switch",
577
+ tenantRoot,
578
+ {
510
579
  branchName,
511
580
  created,
512
581
  resumed,
@@ -517,13 +586,16 @@ async function workflowSwitch(helpers, input) {
517
586
  lastDeploymentTimestamp: state.preview.lastDeploymentTimestamp
518
587
  },
519
588
  previewResult,
520
- state
589
+ preconditions: {
590
+ cleanWorktreeRequired: true,
591
+ baseBranch: STAGING_BRANCH
592
+ }
521
593
  },
522
- nextSteps: createNextSteps([
523
- state.preview.enabled ? { operation: "save", reason: "Persist and verify the current task branch, then refresh its preview deployment.", input: { message: "describe your change" } } : { operation: "dev", reason: "Start the local development environment for this task branch." },
594
+ createNextSteps([
595
+ state.preview.enabled ? { operation: "save", reason: "Persist and verify the current task branch, then refresh its preview deployment.", input: { message: "describe your change", preview: true } } : { operation: "dev", reason: "Start the local development environment for this task branch." },
524
596
  { operation: "stage", reason: "Merge the task into staging once the task branch is verified.", input: { message: "describe the resolution" } }
525
597
  ])
526
- };
598
+ );
527
599
  });
528
600
  } catch (error) {
529
601
  toError("switch", error);
@@ -535,7 +607,8 @@ async function workflowDev(helpers, input = {}) {
535
607
  if (helpers.context.transport === "api") {
536
608
  workflowError("dev", "unsupported_transport", "Treeseed dev is not supported over the HTTP workflow API.");
537
609
  }
538
- const tenantRoot = helpers.cwd();
610
+ const tenantRoot = resolveProjectRootOrThrow("dev", helpers.cwd());
611
+ const readiness = ensureLocalReadinessOrThrow("dev", tenantRoot);
539
612
  applyTreeseedEnvironmentToProcess({ tenantRoot, scope: "local", override: true });
540
613
  assertTreeseedCommandEnvironment({ tenantRoot, scope: "local", purpose: "dev" });
541
614
  const args = [packageScriptPath("tenant-dev")];
@@ -553,38 +626,42 @@ async function workflowDev(helpers, input = {}) {
553
626
  stdio: input.stdio ?? "inherit",
554
627
  detached: process.platform !== "win32"
555
628
  });
556
- return {
557
- ok: true,
558
- operation: "dev",
559
- payload: {
560
- watch: input.watch === true,
561
- background: true,
562
- command: process.execPath,
563
- args,
564
- cwd: tenantRoot,
565
- pid: child.pid ?? null,
566
- exitCode: null
567
- }
568
- };
629
+ return buildWorkflowResult("dev", tenantRoot, {
630
+ watch: input.watch === true,
631
+ background: true,
632
+ command: process.execPath,
633
+ args,
634
+ cwd: tenantRoot,
635
+ pid: child.pid ?? null,
636
+ exitCode: null,
637
+ runtime: {
638
+ mode: process.env.TREESEED_LOCAL_DEV_MODE ?? "cloudflare",
639
+ apiBaseUrl: process.env.TREESEED_API_BASE_URL ?? "http://127.0.0.1:3000",
640
+ webUrl: "http://127.0.0.1:8787"
641
+ },
642
+ readiness: readiness.readiness.local
643
+ });
569
644
  }
570
645
  const result = spawnSync(process.execPath, args, {
571
646
  cwd: tenantRoot,
572
647
  env,
573
648
  stdio: input.stdio ?? "inherit"
574
649
  });
575
- return {
576
- ok: (result.status ?? 1) === 0,
577
- operation: "dev",
578
- payload: {
579
- watch: input.watch === true,
580
- background: false,
581
- command: process.execPath,
582
- args,
583
- cwd: tenantRoot,
584
- pid: null,
585
- exitCode: result.status ?? 1
586
- }
587
- };
650
+ return buildWorkflowResult("dev", tenantRoot, {
651
+ watch: input.watch === true,
652
+ background: false,
653
+ command: process.execPath,
654
+ args,
655
+ cwd: tenantRoot,
656
+ pid: null,
657
+ exitCode: result.status ?? 1,
658
+ runtime: {
659
+ mode: process.env.TREESEED_LOCAL_DEV_MODE ?? "cloudflare",
660
+ apiBaseUrl: process.env.TREESEED_API_BASE_URL ?? "http://127.0.0.1:3000",
661
+ webUrl: "http://127.0.0.1:8787"
662
+ },
663
+ readiness: readiness.readiness.local
664
+ });
588
665
  });
589
666
  } catch (error) {
590
667
  toError("dev", error);
@@ -593,7 +670,7 @@ async function workflowDev(helpers, input = {}) {
593
670
  async function workflowSave(helpers, input) {
594
671
  try {
595
672
  return withContextEnv(helpers.context.env, () => {
596
- const tenantRoot = helpers.cwd();
673
+ const tenantRoot = resolveProjectRootOrThrow("save", helpers.cwd());
597
674
  const message = ensureMessage("save", input.message, "a commit message");
598
675
  const optionsHotfix = input.hotfix === true;
599
676
  const root = workspaceRoot(tenantRoot);
@@ -616,45 +693,49 @@ async function workflowSave(helpers, input) {
616
693
  if (input.verify !== false) {
617
694
  runWorkspaceSavePreflight({ cwd: root });
618
695
  }
619
- if (!hasMeaningfulChanges(gitRoot)) {
620
- workflowError("save", "validation_failed", "Treeseed save found no meaningful repository changes to commit.");
696
+ const hadMeaningfulChanges = hasMeaningfulChanges(gitRoot);
697
+ let head = run("git", ["rev-parse", "HEAD"], { cwd: gitRoot, capture: true }).trim();
698
+ let commitCreated = false;
699
+ if (hadMeaningfulChanges) {
700
+ run("git", ["add", "-A"], { cwd: gitRoot });
701
+ run("git", ["commit", "-m", message], { cwd: gitRoot });
702
+ head = run("git", ["rev-parse", "HEAD"], { cwd: gitRoot, capture: true }).trim();
703
+ commitCreated = true;
621
704
  }
622
- run("git", ["add", "-A"], { cwd: gitRoot });
623
- run("git", ["commit", "-m", message], { cwd: gitRoot });
624
- const head = run("git", ["rev-parse", "HEAD"], { cwd: gitRoot, capture: true }).trim();
625
- try {
626
- if (remoteBranchExists(gitRoot, branch)) {
627
- run("git", ["pull", "--rebase", "origin", branch], { cwd: gitRoot });
628
- run("git", ["push", "origin", branch], { cwd: gitRoot });
629
- } else {
630
- run("git", ["push", "-u", "origin", branch], { cwd: gitRoot });
705
+ const branchSync = syncCurrentBranchToOrigin("save", gitRoot, branch);
706
+ let previewAction = { status: "skipped" };
707
+ if (beforeState.branchRole === "feature" && branch) {
708
+ if (input.preview === true) {
709
+ previewAction = {
710
+ status: beforeState.preview.enabled ? "refreshed" : "created",
711
+ details: deployBranchPreview(root, branch, helpers.context, { initialize: !beforeState.preview.enabled })
712
+ };
713
+ } else if (input.refreshPreview !== false && beforeState.preview.enabled) {
714
+ previewAction = {
715
+ status: "refreshed",
716
+ details: deployBranchPreview(root, branch, helpers.context, { initialize: false })
717
+ };
631
718
  }
632
- } catch {
633
- const report = collectMergeConflictReport(gitRoot);
634
- throw new TreeseedWorkflowError("save", "merge_conflict", formatMergeConflictReport(report, gitRoot, branch), {
635
- details: { branch, report },
636
- exitCode: 12
637
- });
638
- }
639
- let previewRefresh = null;
640
- if (input.refreshPreview !== false && beforeState.branchRole === "feature" && beforeState.preview.enabled && branch) {
641
- previewRefresh = deployBranchPreview(root, branch, helpers.context, { initialize: false });
642
719
  }
643
- return {
644
- ok: true,
645
- operation: "save",
646
- payload: {
720
+ return buildWorkflowResult(
721
+ "save",
722
+ root,
723
+ {
647
724
  branch,
648
725
  scope,
649
726
  hotfix: optionsHotfix,
650
727
  message,
651
728
  commitSha: head,
652
- previewRefresh
729
+ commitCreated,
730
+ noChanges: !hadMeaningfulChanges,
731
+ branchSync,
732
+ previewAction,
733
+ mergeConflict: null
653
734
  },
654
- nextSteps: createNextSteps([
735
+ createNextSteps([
655
736
  branch === STAGING_BRANCH ? { operation: "release", reason: "Promote the validated staging branch into production.", input: { bump: "patch" } } : branch === PRODUCTION_BRANCH ? { operation: "status", reason: "Inspect production state after the explicit hotfix save." } : { operation: "stage", reason: "Merge the verified task branch into staging.", input: { message: "describe the resolution" } }
656
737
  ])
657
- };
738
+ );
658
739
  });
659
740
  } catch (error) {
660
741
  toError("save", error);
@@ -662,9 +743,13 @@ async function workflowSave(helpers, input) {
662
743
  }
663
744
  async function workflowClose(helpers, input) {
664
745
  try {
665
- return withContextEnv(helpers.context.env, () => {
666
- const tenantRoot = helpers.cwd();
746
+ return withContextEnv(helpers.context.env, async () => {
747
+ const tenantRoot = resolveProjectRootOrThrow("close", helpers.cwd());
667
748
  const message = ensureMessage("close", input.message, "a close reason");
749
+ const autoSave = await maybeAutoSaveCurrentTaskBranch(helpers, "close", {
750
+ message,
751
+ autoSave: input.autoSave
752
+ });
668
753
  const featureBranch = assertFeatureBranch(tenantRoot);
669
754
  const repoDir = gitWorkflowRoot(tenantRoot);
670
755
  assertCleanWorktree(tenantRoot);
@@ -675,21 +760,24 @@ async function workflowClose(helpers, input) {
675
760
  if (input.deleteBranch !== false) {
676
761
  deleteLocalBranch(repoDir, featureBranch);
677
762
  }
678
- return {
679
- ok: true,
680
- operation: "close",
681
- payload: {
763
+ return buildWorkflowResult(
764
+ "close",
765
+ tenantRoot,
766
+ {
682
767
  branchName: featureBranch,
683
768
  message,
769
+ autoSaved: autoSave.performed,
770
+ autoSaveResult: autoSave.save,
684
771
  deprecatedTag,
685
772
  previewCleanup,
686
773
  remoteDeleted,
687
- localDeleted: input.deleteBranch !== false
774
+ localDeleted: input.deleteBranch !== false,
775
+ finalBranch: currentBranch(repoDir) || STAGING_BRANCH
688
776
  },
689
- nextSteps: createNextSteps([
777
+ createNextSteps([
690
778
  { operation: "tasks", reason: "Inspect the remaining task branches after closing this one." }
691
779
  ])
692
- };
780
+ );
693
781
  });
694
782
  } catch (error) {
695
783
  toError("close", error);
@@ -697,12 +785,25 @@ async function workflowClose(helpers, input) {
697
785
  }
698
786
  async function workflowStage(helpers, input) {
699
787
  try {
700
- return withContextEnv(helpers.context.env, () => {
701
- const tenantRoot = helpers.cwd();
788
+ return withContextEnv(helpers.context.env, async () => {
789
+ const tenantRoot = resolveProjectRootOrThrow("stage", helpers.cwd());
702
790
  const message = ensureMessage("stage", input.message, "a resolution message");
791
+ const autoSave = await maybeAutoSaveCurrentTaskBranch(helpers, "stage", {
792
+ message,
793
+ autoSave: input.autoSave
794
+ });
703
795
  const featureBranch = assertFeatureBranch(tenantRoot);
704
796
  runWorkspaceSavePreflight({ cwd: tenantRoot });
705
- const repoDir = mergeCurrentBranchIntoStaging(tenantRoot, featureBranch);
797
+ let repoDir;
798
+ try {
799
+ repoDir = mergeCurrentBranchIntoStaging(tenantRoot, featureBranch);
800
+ } catch (error) {
801
+ const report = collectMergeConflictReport(gitWorkflowRoot(tenantRoot));
802
+ throw new TreeseedWorkflowError("stage", "merge_conflict", formatMergeConflictReport(report, gitWorkflowRoot(tenantRoot), STAGING_BRANCH), {
803
+ details: { branch: featureBranch, report, originalError: error instanceof Error ? error.message : String(error) },
804
+ exitCode: 12
805
+ });
806
+ }
706
807
  const stagingWait = input.waitForStaging === false ? { status: "skipped", reason: "disabled" } : waitForStagingAutomation(repoDir);
707
808
  const previewCleanup = input.deletePreview === false ? { performed: false } : destroyPreviewIfPresent(tenantRoot, featureBranch);
708
809
  const deprecatedTag = createDeprecatedTaskTag(repoDir, featureBranch, `stage: ${message}`);
@@ -710,24 +811,27 @@ async function workflowStage(helpers, input) {
710
811
  if (input.deleteBranch !== false) {
711
812
  deleteLocalBranch(repoDir, featureBranch);
712
813
  }
713
- return {
714
- ok: true,
715
- operation: "stage",
716
- payload: {
814
+ return buildWorkflowResult(
815
+ "stage",
816
+ tenantRoot,
817
+ {
717
818
  branchName: featureBranch,
718
819
  mergeTarget: STAGING_BRANCH,
719
820
  message,
821
+ autoSaved: autoSave.performed,
822
+ autoSaveResult: autoSave.save,
720
823
  deprecatedTag,
721
824
  stagingWait,
722
825
  previewCleanup,
723
826
  remoteDeleted,
724
- localDeleted: input.deleteBranch !== false
827
+ localDeleted: input.deleteBranch !== false,
828
+ finalBranch: currentBranch(repoDir) || STAGING_BRANCH
725
829
  },
726
- nextSteps: createNextSteps([
830
+ createNextSteps([
727
831
  { operation: "release", reason: "Promote the updated staging branch into production when ready.", input: { bump: "patch" } },
728
832
  { operation: "status", reason: "Inspect staging readiness after the task branch merge." }
729
833
  ])
730
- };
834
+ );
731
835
  });
732
836
  } catch (error) {
733
837
  toError("stage", error);
@@ -737,7 +841,7 @@ async function workflowRelease(helpers, input) {
737
841
  try {
738
842
  return withContextEnv(helpers.context.env, () => {
739
843
  const level = input.bump ?? "patch";
740
- const root = workspaceRoot(helpers.cwd());
844
+ const root = resolveProjectRootOrThrow("release", helpers.cwd());
741
845
  const gitRoot = repoRoot(root);
742
846
  prepareReleaseBranches(root);
743
847
  applyTreeseedEnvironmentToProcess({ tenantRoot: root, scope: "staging", override: true });
@@ -750,23 +854,32 @@ async function workflowRelease(helpers, input) {
750
854
  run("git", ["commit", "-m", `release: ${level} bump`], { cwd: gitRoot });
751
855
  pushBranch(gitRoot, STAGING_BRANCH);
752
856
  mergeStagingIntoMain(root);
857
+ const releasedCommit = run("git", ["rev-parse", PRODUCTION_BRANCH], { cwd: gitRoot, capture: true }).trim();
753
858
  run("git", ["tag", "-a", rootVersion, "-m", `release: ${rootVersion}`], { cwd: gitRoot });
754
859
  run("git", ["push", "origin", rootVersion], { cwd: gitRoot });
755
- return {
756
- ok: true,
757
- operation: "release",
758
- payload: {
860
+ syncBranchWithOrigin(gitRoot, STAGING_BRANCH);
861
+ return buildWorkflowResult(
862
+ "release",
863
+ root,
864
+ {
759
865
  level,
760
866
  rootVersion,
761
867
  releaseTag: rootVersion,
868
+ releasedCommit,
762
869
  stagingBranch: STAGING_BRANCH,
763
870
  productionBranch: PRODUCTION_BRANCH,
764
- touchedPackages: [...plan.touched]
871
+ touchedPackages: [...plan.touched],
872
+ finalBranch: currentBranch(gitRoot) || STAGING_BRANCH,
873
+ pushStatus: {
874
+ stagingPushed: true,
875
+ productionPushed: true,
876
+ tagPushed: true
877
+ }
765
878
  },
766
- nextSteps: createNextSteps([
879
+ createNextSteps([
767
880
  { operation: "status", reason: "Inspect release readiness and production state after the promotion." }
768
881
  ])
769
- };
882
+ );
770
883
  });
771
884
  } catch (error) {
772
885
  toError("release", error);
@@ -832,6 +945,7 @@ export {
832
945
  workflowConfig,
833
946
  workflowDestroy,
834
947
  workflowDev,
948
+ workflowExport,
835
949
  workflowRelease,
836
950
  workflowSave,
837
951
  workflowStage,