@treeseed/sdk 0.6.21 → 0.6.23

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.
@@ -220,6 +220,7 @@ export declare function resolveTreeseedRemoteConfig(startRoot?: string, env?: No
220
220
  export declare function resolveTreeseedTemplateCatalogEndpoint(startRoot?: string, env?: NodeJS.ProcessEnv): string;
221
221
  export declare function resolveTreeseedTemplateCatalogCachePath(startRoot?: string): string;
222
222
  export declare function ensureTreeseedGitignoreEntries(tenantRoot: any): string;
223
+ export declare function ensureTreeseedRailwayIgnoreEntries(tenantRoot: any): string;
223
224
  export type TreeseedRepairAction = {
224
225
  id: string;
225
226
  detail: string;
@@ -1210,6 +1210,46 @@ function ensureTreeseedGitignoreEntries(tenantRoot) {
1210
1210
  }
1211
1211
  return gitignorePath;
1212
1212
  }
1213
+ function ensureTreeseedRailwayIgnoreEntries(tenantRoot) {
1214
+ const railwayIgnorePath = resolve(tenantRoot, ".railwayignore");
1215
+ const requiredEntries = [
1216
+ ".astro/",
1217
+ ".codex/",
1218
+ ".dev.vars",
1219
+ ".env.local",
1220
+ ".git/",
1221
+ ".treeseed/",
1222
+ ".wrangler/",
1223
+ "coverage/",
1224
+ "dist/",
1225
+ "node_modules/",
1226
+ "npm-debug.log*",
1227
+ "packages/*/.git/",
1228
+ "packages/*/dist/",
1229
+ "packages/*/node_modules/",
1230
+ "public/__treeseed/*.json",
1231
+ "public/books/*.json",
1232
+ "public/books/*.md",
1233
+ "scripts/.ts-run-*.mjs",
1234
+ "tmp/",
1235
+ "*.log",
1236
+ "*.tgz"
1237
+ ];
1238
+ const current = existsSync(railwayIgnorePath) ? readFileSync(railwayIgnorePath, "utf8") : "";
1239
+ const lines = current.split(/\r?\n/);
1240
+ let changed = false;
1241
+ for (const entry of requiredEntries) {
1242
+ if (!lines.includes(entry)) {
1243
+ lines.push(entry);
1244
+ changed = true;
1245
+ }
1246
+ }
1247
+ if (changed || !existsSync(railwayIgnorePath)) {
1248
+ writeFileSync(railwayIgnorePath, `${lines.filter(Boolean).join("\n")}
1249
+ `, "utf8");
1250
+ }
1251
+ return railwayIgnorePath;
1252
+ }
1213
1253
  function dedupeRepairActions(actions) {
1214
1254
  const seen = /* @__PURE__ */ new Set();
1215
1255
  return actions.filter((action) => {
@@ -1224,6 +1264,8 @@ function applyTreeseedSafeRepairs(tenantRoot) {
1224
1264
  const actions = [];
1225
1265
  ensureTreeseedGitignoreEntries(tenantRoot);
1226
1266
  actions.push({ id: "gitignore", detail: "Ensured Treeseed gitignore entries are present." });
1267
+ ensureTreeseedRailwayIgnoreEntries(tenantRoot);
1268
+ actions.push({ id: "railwayignore", detail: "Ensured Railway deploy ignore entries are present." });
1227
1269
  const deprecatedFiles = warnDeprecatedTreeseedLocalEnvFiles(tenantRoot);
1228
1270
  if (deprecatedFiles.length > 0) {
1229
1271
  actions.push({ id: "deprecated-local-env", detail: "Detected deprecated .env.local/.dev.vars files that Treeseed now ignores." });
@@ -2382,6 +2424,7 @@ function collectTreeseedConfigContext({
2382
2424
  env = process.env
2383
2425
  }) {
2384
2426
  ensureTreeseedGitignoreEntries(tenantRoot);
2427
+ ensureTreeseedRailwayIgnoreEntries(tenantRoot);
2385
2428
  const registry = collectTreeseedEnvironmentContext(tenantRoot);
2386
2429
  const { configPath, keyPath } = getTreeseedMachineConfigPaths(tenantRoot);
2387
2430
  const valuesByScope = Object.fromEntries(
@@ -2843,6 +2886,7 @@ export {
2843
2886
  createDefaultTreeseedMachineConfig,
2844
2887
  ensureTreeseedActVerificationTooling,
2845
2888
  ensureTreeseedGitignoreEntries,
2889
+ ensureTreeseedRailwayIgnoreEntries,
2846
2890
  ensureTreeseedSecretSessionForConfig,
2847
2891
  finalizeTreeseedConfig,
2848
2892
  formatTreeseedConfigEnvironmentReport,
@@ -325,7 +325,6 @@ const LOCAL_RUNTIME_AUTH_ENV_KEYS = [
325
325
  "TREESEED_AUTH_INTERNAL_SIGNUP",
326
326
  "TREESEED_AUTH_EMAIL_LINKING",
327
327
  "TREESEED_AUTH_ALLOW_MEMORY_DB",
328
- "TREESEED_AUTH_LOCAL_USE_MAILPIT",
329
328
  "TREESEED_AUTH_EMAIL_FROM",
330
329
  "TREESEED_WEB_SESSION_TTL",
331
330
  "TREESEED_API_BOOTSTRAP_ADMIN_ALLOWLIST",
@@ -362,9 +361,8 @@ function buildLocalRuntimeVars(deployConfig, state, target, env) {
362
361
  TREESEED_BETTER_AUTH_SECRET: envValue(env, "TREESEED_BETTER_AUTH_SECRET") ?? state.generatedSecrets?.TREESEED_BETTER_AUTH_SECRET ?? state.generatedSecrets?.TREESEED_FORM_TOKEN_SECRET ?? "treeseed-local-better-auth-secret-minimum-32-characters",
363
362
  TREESEED_EDITORIAL_PREVIEW_SECRET: envValue(env, "TREESEED_EDITORIAL_PREVIEW_SECRET") ?? state.generatedSecrets?.TREESEED_EDITORIAL_PREVIEW_SECRET ?? "treeseed-local-editorial-preview-secret",
364
363
  TREESEED_FORMS_LOCAL_BYPASS_CLOUDFLARE_GUARDS: envValue(env, "TREESEED_FORMS_LOCAL_BYPASS_CLOUDFLARE_GUARDS") ?? "",
365
- TREESEED_FORMS_LOCAL_USE_MAILPIT: envValue(env, "TREESEED_FORMS_LOCAL_USE_MAILPIT") ?? "false",
366
- TREESEED_MAILPIT_SMTP_HOST: envValue(env, "TREESEED_MAILPIT_SMTP_HOST") ?? "127.0.0.1",
367
- TREESEED_MAILPIT_SMTP_PORT: envValue(env, "TREESEED_MAILPIT_SMTP_PORT") ?? "1025"
364
+ TREESEED_SMTP_HOST: envValue(env, "TREESEED_SMTP_HOST") ?? "127.0.0.1",
365
+ TREESEED_SMTP_PORT: envValue(env, "TREESEED_SMTP_PORT") ?? "1025"
368
366
  };
369
367
  }
370
368
  function buildSecretMap(deployConfig, state) {
@@ -1,6 +1,74 @@
1
1
  export declare const STAGING_BRANCH = "staging";
2
2
  export declare const PRODUCTION_BRANCH = "main";
3
3
  export declare function headCommit(repoDir: any, ref?: string): string;
4
+ export declare function inspectDetachedHeadRepair(repoDir: any, expectedBranches?: string[]): {
5
+ repoDir: any;
6
+ branchName: string;
7
+ detached: boolean;
8
+ dirty: boolean;
9
+ headSha: string | null;
10
+ targetBranch: string;
11
+ targetSha: string | null;
12
+ repairable: boolean;
13
+ repaired: boolean;
14
+ blocker: null;
15
+ } | {
16
+ repoDir: any;
17
+ branchName: null;
18
+ detached: boolean;
19
+ dirty: boolean;
20
+ headSha: string;
21
+ targetBranch: string;
22
+ targetSha: string;
23
+ repairable: boolean;
24
+ repaired: boolean;
25
+ blocker: null;
26
+ } | {
27
+ repoDir: any;
28
+ branchName: null;
29
+ detached: boolean;
30
+ dirty: boolean;
31
+ headSha: string | null;
32
+ targetBranch: null;
33
+ targetSha: null;
34
+ repairable: boolean;
35
+ repaired: boolean;
36
+ blocker: string;
37
+ };
38
+ export declare function reattachDetachedHeadIfSafe(repoDir: any, expectedBranches?: string[]): {
39
+ repoDir: any;
40
+ branchName: string;
41
+ detached: boolean;
42
+ dirty: boolean;
43
+ headSha: string | null;
44
+ targetBranch: string;
45
+ targetSha: string | null;
46
+ repairable: boolean;
47
+ repaired: boolean;
48
+ blocker: null;
49
+ } | {
50
+ repoDir: any;
51
+ branchName: null;
52
+ detached: boolean;
53
+ dirty: boolean;
54
+ headSha: string;
55
+ targetBranch: string;
56
+ targetSha: string;
57
+ repairable: boolean;
58
+ repaired: boolean;
59
+ blocker: null;
60
+ } | {
61
+ repoDir: any;
62
+ branchName: null;
63
+ detached: boolean;
64
+ dirty: boolean;
65
+ headSha: string | null;
66
+ targetBranch: null;
67
+ targetSha: null;
68
+ repairable: boolean;
69
+ repaired: boolean;
70
+ blocker: string;
71
+ };
4
72
  export declare function gitWorkflowRoot(cwd?: any): string;
5
73
  export declare function assertCleanWorktree(cwd?: any): string;
6
74
  export declare function assertCleanWorktrees(repoDirs: any): any;
@@ -40,6 +40,75 @@ function resolveGeneratedPackageMetadataConflicts(repoDir) {
40
40
  function headCommit(repoDir, ref = "HEAD") {
41
41
  return runGit(["rev-parse", ref], { cwd: repoDir, capture: true }).trim();
42
42
  }
43
+ function maybeHeadCommit(repoDir, ref = "HEAD") {
44
+ try {
45
+ return headCommit(repoDir, ref);
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+ function inspectDetachedHeadRepair(repoDir, expectedBranches = [STAGING_BRANCH, PRODUCTION_BRANCH]) {
51
+ const branchName = currentBranch(repoDir) || null;
52
+ const headSha = maybeHeadCommit(repoDir);
53
+ const dirty = gitStatusPorcelain(repoDir).length > 0;
54
+ if (branchName) {
55
+ return {
56
+ repoDir,
57
+ branchName,
58
+ detached: false,
59
+ dirty,
60
+ headSha,
61
+ targetBranch: branchName,
62
+ targetSha: headSha,
63
+ repairable: false,
64
+ repaired: false,
65
+ blocker: null
66
+ };
67
+ }
68
+ for (const branch of expectedBranches) {
69
+ const branchSha = branchExists(repoDir, branch) ? maybeHeadCommit(repoDir, branch) : null;
70
+ if (headSha && branchSha && headSha === branchSha) {
71
+ return {
72
+ repoDir,
73
+ branchName: null,
74
+ detached: true,
75
+ dirty,
76
+ headSha,
77
+ targetBranch: branch,
78
+ targetSha: branchSha,
79
+ repairable: true,
80
+ repaired: false,
81
+ blocker: null
82
+ };
83
+ }
84
+ }
85
+ const expected = expectedBranches.join(" or ");
86
+ return {
87
+ repoDir,
88
+ branchName: null,
89
+ detached: true,
90
+ dirty,
91
+ headSha,
92
+ targetBranch: null,
93
+ targetSha: null,
94
+ repairable: false,
95
+ repaired: false,
96
+ blocker: `Detached HEAD ${headSha ?? "(unknown)"} does not match ${expected}; review manually before continuing.`
97
+ };
98
+ }
99
+ function reattachDetachedHeadIfSafe(repoDir, expectedBranches = [STAGING_BRANCH, PRODUCTION_BRANCH]) {
100
+ const inspection = inspectDetachedHeadRepair(repoDir, expectedBranches);
101
+ if (!inspection.detached || !inspection.repairable || !inspection.targetBranch) {
102
+ return inspection;
103
+ }
104
+ runGit(["switch", inspection.targetBranch], { cwd: repoDir });
105
+ return {
106
+ ...inspection,
107
+ branchName: inspection.targetBranch,
108
+ detached: false,
109
+ repaired: true
110
+ };
111
+ }
43
112
  function gitWorkflowRoot(cwd = workspaceRoot()) {
44
113
  return repoRoot(cwd);
45
114
  }
@@ -394,6 +463,7 @@ export {
394
463
  fetchOrigin,
395
464
  gitWorkflowRoot,
396
465
  headCommit,
466
+ inspectDetachedHeadRepair,
397
467
  isTaskBranch,
398
468
  listTaskBranches,
399
469
  mergeBranchIntoTarget,
@@ -402,6 +472,7 @@ export {
402
472
  prepareReleaseBranches,
403
473
  pushBranch,
404
474
  pushHeadToBranch,
475
+ reattachDetachedHeadIfSafe,
405
476
  remoteBranchExists,
406
477
  squashMergeBranchIntoStaging,
407
478
  syncBranchWithOrigin,
@@ -119,5 +119,7 @@ export declare function formatGitHubActionsGateFailure(gate: GitHubActionsWorkfl
119
119
  export declare function waitForGitHubActionsGate(gate: GitHubActionsWorkflowGate, options?: {
120
120
  timeoutSeconds?: number;
121
121
  pollSeconds?: number;
122
+ operation?: string;
123
+ onProgress?: (message: string, stream?: 'stdout' | 'stderr') => void;
122
124
  }): Promise<Record<string, unknown>>;
123
125
  export {};
@@ -421,6 +421,31 @@ Failed jobs: ${failedJobs.join(", ")}` : "";
421
421
  Inspect with: gh run view ${runId} --repo ${repository} --log-failed` : "";
422
422
  return `${gate.name} ${gate.workflow} completed with conclusion ${String(result.conclusion ?? "unknown")} in ${repository}.${url}${jobLine}${command}`;
423
423
  }
424
+ function formatElapsed(seconds) {
425
+ const safe = Math.max(0, Math.round(seconds));
426
+ if (safe < 60) return `${safe}s`;
427
+ const minutes = Math.floor(safe / 60);
428
+ const remainder = safe % 60;
429
+ return remainder === 0 ? `${minutes}m` : `${minutes}m${remainder}s`;
430
+ }
431
+ function shortSha(value) {
432
+ return value ? value.slice(0, 12) : "(unknown)";
433
+ }
434
+ function formatGitHubActionsGateProgress(gate, event, operation) {
435
+ const prefix = `[${operation}][gate][${gate.name}] ${event.workflow}`;
436
+ if (event.type === "waiting") {
437
+ return `${prefix} on ${event.branch ?? gate.branch}: waiting for run for ${shortSha(event.headSha ?? gate.headSha)} (${formatElapsed(event.elapsedSeconds)} elapsed)`;
438
+ }
439
+ if (event.type === "completed") {
440
+ const conclusion = event.conclusion === "success" ? "successfully" : `with conclusion ${event.conclusion ?? "unknown"}`;
441
+ const url2 = event.url ? `: ${event.url}` : "";
442
+ return `${prefix} completed ${conclusion} in ${formatElapsed(event.elapsedSeconds)}${url2}`;
443
+ }
444
+ const status = event.status ?? "waiting";
445
+ const url = event.url ? `: ${event.url}` : "";
446
+ const run = event.runId ? ` run ${event.runId}` : "";
447
+ return `${prefix}${run} ${status}${url} (${formatElapsed(event.elapsedSeconds)} elapsed)`;
448
+ }
424
449
  async function waitForGitHubActionsGate(gate, options = {}) {
425
450
  const { waitForGitHubWorkflowCompletion } = await import("./github-automation.js");
426
451
  return await waitForGitHubWorkflowCompletion(gate.repoPath, {
@@ -429,7 +454,10 @@ async function waitForGitHubActionsGate(gate, options = {}) {
429
454
  headSha: gate.headSha,
430
455
  branch: gate.branch,
431
456
  timeoutSeconds: options.timeoutSeconds,
432
- pollSeconds: options.pollSeconds
457
+ pollSeconds: options.pollSeconds,
458
+ onProgress: (event) => {
459
+ options.onProgress?.(formatGitHubActionsGateProgress(gate, event, options.operation ?? "workflow"));
460
+ }
433
461
  });
434
462
  }
435
463
  export {
@@ -34,6 +34,18 @@ export interface GitHubWorkflowJobSummary {
34
34
  conclusion: string | null;
35
35
  url: string | null;
36
36
  }
37
+ export type GitHubWorkflowProgressEvent = {
38
+ type: 'waiting' | 'running' | 'completed';
39
+ repository: string;
40
+ workflow: string;
41
+ branch: string | null;
42
+ headSha: string | null;
43
+ elapsedSeconds: number;
44
+ runId: number | null;
45
+ url: string | null;
46
+ status: string | null;
47
+ conclusion: string | null;
48
+ };
37
49
  export declare function resolveGitHubApiToken(env?: NodeJS.ProcessEnv | Record<string, string | undefined>): string;
38
50
  export declare function parseGitHubRepositorySlug(value: string): {
39
51
  owner: string;
@@ -125,13 +137,14 @@ export declare function upsertGitHubRepositoryVariableWithGhCli(repository: stri
125
137
  export declare function waitForGitHubWorkflowRunCompletion(repository: string | {
126
138
  owner: string;
127
139
  name: string;
128
- }, { client, workflow, headSha, branch, timeoutSeconds, pollSeconds, }?: {
140
+ }, { client, workflow, headSha, branch, timeoutSeconds, pollSeconds, onProgress, }?: {
129
141
  client?: GitHubApiClient;
130
142
  workflow?: string;
131
143
  headSha?: string | null;
132
144
  branch?: string | null;
133
145
  timeoutSeconds?: number;
134
146
  pollSeconds?: number;
147
+ onProgress?: (event: GitHubWorkflowProgressEvent) => void;
135
148
  }): Promise<{
136
149
  status: string;
137
150
  repository: string;
@@ -541,10 +541,28 @@ async function waitForGitHubWorkflowRunCompletion(repository, {
541
541
  headSha,
542
542
  branch,
543
543
  timeoutSeconds = 600,
544
- pollSeconds = 5
544
+ pollSeconds = 5,
545
+ onProgress
545
546
  } = {}) {
546
547
  const { owner, name } = typeof repository === "string" ? parseGitHubRepositorySlug(repository) : repository;
547
548
  const startedAt = Date.now();
549
+ let lastProgress = null;
550
+ const emitProgress = (type, run = null) => {
551
+ const event = {
552
+ type,
553
+ repository: `${owner}/${name}`,
554
+ workflow,
555
+ branch: run?.headBranch ?? branch ?? null,
556
+ headSha: run?.headSha ?? headSha ?? null,
557
+ elapsedSeconds: Math.max(0, Math.round((Date.now() - startedAt) / 1e3)),
558
+ runId: run?.id ?? null,
559
+ url: run?.url ?? null,
560
+ status: run?.status ?? null,
561
+ conclusion: run?.conclusion ?? null
562
+ };
563
+ lastProgress = event;
564
+ onProgress?.(event);
565
+ };
548
566
  while (Date.now() - startedAt < timeoutSeconds * 1e3) {
549
567
  try {
550
568
  const listed = await client.rest.actions.listWorkflowRuns({
@@ -555,10 +573,14 @@ async function waitForGitHubWorkflowRunCompletion(repository, {
555
573
  });
556
574
  const match = listed.data.workflow_runs.map((run) => normalizeWorkflowRun(run)).find((run) => (!headSha || run.headSha === headSha) && (!branch || run.headBranch === branch));
557
575
  if (!match?.id) {
576
+ emitProgress("waiting");
558
577
  await sleep(pollSeconds * 1e3);
559
578
  continue;
560
579
  }
561
580
  for (; ; ) {
581
+ if (Date.now() - startedAt >= timeoutSeconds * 1e3) {
582
+ break;
583
+ }
562
584
  const current = await client.rest.actions.getWorkflowRun({
563
585
  owner,
564
586
  repo: name,
@@ -566,6 +588,7 @@ async function waitForGitHubWorkflowRunCompletion(repository, {
566
588
  });
567
589
  const normalized = normalizeWorkflowRun(current.data);
568
590
  if (normalized.status === "completed") {
591
+ emitProgress("completed", normalized);
569
592
  const jobs = await client.rest.actions.listJobsForWorkflowRun({
570
593
  owner,
571
594
  repo: name,
@@ -586,13 +609,15 @@ async function waitForGitHubWorkflowRunCompletion(repository, {
586
609
  failedJobs: normalizedJobs.filter((job) => job.conclusion && job.conclusion !== "success" && job.conclusion !== "skipped")
587
610
  };
588
611
  }
612
+ emitProgress("running", normalized);
589
613
  await sleep(pollSeconds * 1e3);
590
614
  }
591
615
  } catch (error) {
592
616
  throw normalizeGitHubApiError(error, `Unable to monitor GitHub workflow ${workflow} in ${owner}/${name}`);
593
617
  }
594
618
  }
595
- throw new Error(`Timed out waiting for GitHub workflow ${workflow} in ${owner}/${name}.`);
619
+ const lastState = lastProgress ? ` Last known state: run ${lastProgress.runId ?? "(not created)"} ${lastProgress.status ?? "waiting"}${lastProgress.conclusion ? `/${lastProgress.conclusion}` : ""}${lastProgress.url ? ` ${lastProgress.url}` : ""}.` : "";
620
+ throw new Error(`Timed out waiting for GitHub workflow ${workflow} in ${owner}/${name}.${lastState}`);
596
621
  }
597
622
  async function ensureGitHubBranchFromBase(repository, branch, {
598
623
  baseBranch = "main",
@@ -271,7 +271,7 @@ export declare function ensureGitHubDeployAutomation(tenantRoot: any, { dryRun }
271
271
  mode?: undefined;
272
272
  };
273
273
  }>;
274
- export declare function waitForGitHubWorkflowCompletion(tenantRoot: any, { repository, workflow, headSha, branch, timeoutSeconds, pollSeconds, }?: {
274
+ export declare function waitForGitHubWorkflowCompletion(tenantRoot: any, { repository, workflow, headSha, branch, timeoutSeconds, pollSeconds, onProgress, }?: {
275
275
  workflow?: string | undefined;
276
276
  timeoutSeconds?: number | undefined;
277
277
  pollSeconds?: number | undefined;
@@ -502,7 +502,8 @@ async function waitForGitHubWorkflowCompletion(tenantRoot, {
502
502
  headSha,
503
503
  branch,
504
504
  timeoutSeconds = 600,
505
- pollSeconds = 5
505
+ pollSeconds = 5,
506
+ onProgress
506
507
  } = {}) {
507
508
  if (isGitHubAutomationStubbed()) {
508
509
  return {
@@ -521,7 +522,8 @@ async function waitForGitHubWorkflowCompletion(tenantRoot, {
521
522
  headSha,
522
523
  branch,
523
524
  timeoutSeconds,
524
- pollSeconds
525
+ pollSeconds,
526
+ onProgress
525
527
  });
526
528
  }
527
529
  export {
@@ -47,8 +47,8 @@ function runLocalD1Migration(persistTo) {
47
47
  }
48
48
  function prepareCloudflareLocalRuntime({ envOverrides = {}, persistTo, outDir } = {}) {
49
49
  const mergedEnvOverrides = {
50
- TREESEED_MAILPIT_SMTP_HOST: "127.0.0.1",
51
- TREESEED_MAILPIT_SMTP_PORT: "1025",
50
+ TREESEED_SMTP_HOST: "127.0.0.1",
51
+ TREESEED_SMTP_PORT: "1025",
52
52
  ...envOverrides
53
53
  };
54
54
  runNodeScript("./scripts/patch-starlight-content-path.js");
@@ -929,7 +929,7 @@ entries:
929
929
  sourcePriority:
930
930
  - machine-config
931
931
  - process-env
932
- localDefaultValueRef: localMailpitHostDefault
932
+ localDefaultValueRef: localSmtpHostDefault
933
933
  relevanceRef: smtpEnabled
934
934
  requiredWhenRef: smtpNonLocal
935
935
  TREESEED_SMTP_PORT:
@@ -960,7 +960,7 @@ entries:
960
960
  sourcePriority:
961
961
  - machine-config
962
962
  - process-env
963
- localDefaultValueRef: localMailpitPortDefault
963
+ localDefaultValueRef: localSmtpPortDefault
964
964
  relevanceRef: smtpEnabled
965
965
  requiredWhenRef: smtpNonLocal
966
966
  TREESEED_SMTP_USERNAME:
@@ -108,10 +108,10 @@ function generatedSecret(bytes = 24) {
108
108
  function localTimezoneDefault() {
109
109
  return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
110
110
  }
111
- function localMailpitHostDefault() {
111
+ function localSmtpHostDefault() {
112
112
  return "127.0.0.1";
113
113
  }
114
- function localMailpitPortDefault() {
114
+ function localSmtpPortDefault() {
115
115
  return "1025";
116
116
  }
117
117
  function contactEmailDefault(context) {
@@ -232,8 +232,8 @@ function resolveGitHubRepositoryNameDefault(context) {
232
232
  const VALUE_RESOLVERS = {
233
233
  generatedSecret: () => generatedSecret(),
234
234
  localFormsBypassDefault: () => "true",
235
- localMailpitHostDefault: () => localMailpitHostDefault(),
236
- localMailpitPortDefault: () => localMailpitPortDefault(),
235
+ localSmtpHostDefault: () => localSmtpHostDefault(),
236
+ localSmtpPortDefault: () => localSmtpPortDefault(),
237
237
  contactEmailDefault: (context) => contactEmailDefault(context),
238
238
  projectDomainsDefault: (context) => primaryHostFromUrl(context.deployConfig.siteUrl),
239
239
  apiBaseUrlDefault: (context, scope, values) => resolveConfiguredApiBaseUrl(context, scope, values),
@@ -137,9 +137,8 @@ async function main() {
137
137
  envOverrides: {
138
138
  TREESEED_LOCAL_DEV_MODE: 'cloudflare',
139
139
  TREESEED_FORMS_LOCAL_BYPASS_CLOUDFLARE_GUARDS: 'false',
140
- TREESEED_FORMS_LOCAL_USE_MAILPIT: 'true',
141
- TREESEED_MAILPIT_SMTP_HOST: '127.0.0.1',
142
- TREESEED_MAILPIT_SMTP_PORT: '1025',
140
+ TREESEED_SMTP_HOST: '127.0.0.1',
141
+ TREESEED_SMTP_PORT: '1025',
143
142
  },
144
143
  });
145
144
  const worker = startWranglerDev(['--port', String(TEST_PORT), '--persist-to', PERSIST_TO], {
@@ -61,6 +61,7 @@ import {
61
61
  PRODUCTION_BRANCH,
62
62
  pushBranch,
63
63
  pushHeadToBranch,
64
+ reattachDetachedHeadIfSafe,
64
65
  remoteBranchExists,
65
66
  STAGING_BRANCH,
66
67
  squashMergeBranchIntoStaging,
@@ -377,7 +378,10 @@ async function waitForWorkflowGates(operation, gates, ciMode, options = {}) {
377
378
  continue;
378
379
  }
379
380
  }
380
- const result = await waitForGitHubActionsGate(gate);
381
+ const result = await waitForGitHubActionsGate(gate, {
382
+ operation,
383
+ onProgress: options.onProgress
384
+ });
381
385
  const normalized = {
382
386
  name: gate.name,
383
387
  ...result,
@@ -1731,6 +1735,27 @@ function syncAllCheckedOutPackageRepos(root, branchName) {
1731
1735
  syncBranchWithOrigin(pkg.dir, branchName);
1732
1736
  }
1733
1737
  }
1738
+ function reattachRepairablePackageRepos(root, expectedBranches = [STAGING_BRANCH, PRODUCTION_BRANCH], options = {}) {
1739
+ const reports = checkedOutWorkspacePackageRepos(root).map((pkg) => {
1740
+ const report = reattachDetachedHeadIfSafe(pkg.dir, expectedBranches);
1741
+ if (report.repaired && report.targetBranch && report.headSha) {
1742
+ options.onProgress?.(`[workflow][repair] Reattached ${pkg.name} to ${report.targetBranch} at ${report.headSha.slice(0, 12)}.`);
1743
+ }
1744
+ return {
1745
+ name: pkg.name,
1746
+ path: pkg.dir,
1747
+ ...report
1748
+ };
1749
+ });
1750
+ const blockers = reports.filter((report) => report.detached && !report.repairable).map((report) => `${report.name}: ${report.blocker ?? "detached HEAD requires manual review."}`);
1751
+ if (blockers.length > 0 && options.throwOnBlocker) {
1752
+ workflowError(options.operation ?? "release", "validation_failed", `Detached package heads require manual recovery:
1753
+ ${blockers.join("\n")}`, {
1754
+ details: { blockers, reports }
1755
+ });
1756
+ }
1757
+ return { reports, blockers };
1758
+ }
1734
1759
  function collectReleasePackageSelection(root) {
1735
1760
  const publishable = sortWorkspacePackages(
1736
1761
  publishableWorkspacePackages(root).filter((pkg) => pkg.name?.startsWith("@treeseed/"))
@@ -2143,11 +2168,16 @@ async function workflowSwitch(helpers, input) {
2143
2168
  return await withContextEnv(helpers.context.env, async () => {
2144
2169
  const tenantRoot = resolveProjectRootOrThrow("switch", helpers.cwd());
2145
2170
  const root = workspaceRoot(tenantRoot);
2146
- const session = resolveTreeseedWorkflowSession(root);
2147
2171
  const branchName = String(input.branch ?? input.branchName ?? "").trim();
2148
2172
  if (!branchName) {
2149
2173
  workflowError("switch", "validation_failed", "Treeseed switch requires a branch name.");
2150
2174
  }
2175
+ reattachRepairablePackageRepos(root, [branchName, STAGING_BRANCH, PRODUCTION_BRANCH], {
2176
+ operation: "switch",
2177
+ onProgress: (line, stream) => helpers.write(line, stream),
2178
+ throwOnBlocker: true
2179
+ });
2180
+ const session = resolveTreeseedWorkflowSession(root);
2151
2181
  const preview = input.preview === true;
2152
2182
  const executionMode = normalizeExecutionMode(input);
2153
2183
  if (executionMode !== "plan" && shouldDispatchSwitchToManagedWorktree(root, input, helpers.context.env)) {
@@ -2410,6 +2440,12 @@ async function workflowSave(helpers, input) {
2410
2440
  return await withContextEnv(helpers.context.env, async () => {
2411
2441
  const tenantRoot = resolveProjectRootOrThrow("save", helpers.cwd());
2412
2442
  const root = workspaceRoot(tenantRoot);
2443
+ const rootBranch = currentBranch(repoRoot(root)) || null;
2444
+ reattachRepairablePackageRepos(root, [rootBranch, STAGING_BRANCH, PRODUCTION_BRANCH].filter((branch2) => Boolean(branch2)), {
2445
+ operation: "save",
2446
+ onProgress: (line, stream) => helpers.write(line, stream),
2447
+ throwOnBlocker: true
2448
+ });
2413
2449
  const session = resolveTreeseedWorkflowSession(root);
2414
2450
  const gitRoot = session.gitRoot;
2415
2451
  const branch = session.branchName;
@@ -3275,6 +3311,11 @@ async function workflowRelease(helpers, input) {
3275
3311
  try {
3276
3312
  return await withContextEnv(helpers.context.env, async () => {
3277
3313
  const root = resolveProjectRootOrThrow("release", helpers.cwd());
3314
+ reattachRepairablePackageRepos(root, [STAGING_BRANCH, PRODUCTION_BRANCH], {
3315
+ operation: "release",
3316
+ onProgress: (line, stream) => helpers.write(line, stream),
3317
+ throwOnBlocker: true
3318
+ });
3278
3319
  const session = resolveTreeseedWorkflowSession(root);
3279
3320
  const gitRoot = session.gitRoot;
3280
3321
  const mode = session.mode;
@@ -3439,7 +3480,11 @@ async function workflowRelease(helpers, input) {
3439
3480
  branch: PRODUCTION_BRANCH,
3440
3481
  headSha: String(rootRelease2?.releasedCommit ?? rootRepo.commitSha ?? "")
3441
3482
  }
3442
- ].filter((gate) => gate.headSha), ciMode, { root, runId: workflowRun.runId }).then((workflowGates) => ({ workflowGates })));
3483
+ ].filter((gate) => gate.headSha), ciMode, {
3484
+ root,
3485
+ runId: workflowRun.runId,
3486
+ onProgress: (line, stream) => helpers.write(line, stream)
3487
+ }).then((workflowGates) => ({ workflowGates })));
3443
3488
  const releaseBackMerge2 = await executeJournalStep(root, workflowRun.runId, "release-back-merge", () => backMergeRootProductionIntoStaging(root, false));
3444
3489
  const workspaceLinks2 = ensureWorkflowWorkspaceLinks(root, helpers, effectiveInput.workspaceLinks ?? "auto");
3445
3490
  const payload2 = {
@@ -3556,7 +3601,11 @@ async function workflowRelease(helpers, input) {
3556
3601
  headSha: mergeResult.commitSha,
3557
3602
  branch: PRODUCTION_BRANCH
3558
3603
  }
3559
- ], ciMode, { root, runId: workflowRun.runId });
3604
+ ], ciMode, {
3605
+ root,
3606
+ runId: workflowRun.runId,
3607
+ onProgress: (line, stream) => helpers.write(line, stream)
3608
+ });
3560
3609
  const publish = workflowGates.find((gate) => gate.workflow === "publish.yml") ?? workflowGates[0] ?? null;
3561
3610
  assertReleaseGitHubWorkflowSucceeded(pkg.name, publish);
3562
3611
  const backMerge = backMergeProductionIntoStaging(pkg.dir, pkg.name);
@@ -3667,7 +3716,11 @@ async function workflowRelease(helpers, input) {
3667
3716
  branch: PRODUCTION_BRANCH,
3668
3717
  headSha: String(rootRelease?.releasedCommit ?? rootRepo.commitSha ?? "")
3669
3718
  }
3670
- ].filter((gate) => gate.headSha), ciMode, { root, runId: workflowRun.runId }).then((workflowGates) => ({ workflowGates })));
3719
+ ].filter((gate) => gate.headSha), ciMode, {
3720
+ root,
3721
+ runId: workflowRun.runId,
3722
+ onProgress: (line, stream) => helpers.write(line, stream)
3723
+ }).then((workflowGates) => ({ workflowGates })));
3671
3724
  const releaseBackMerge = await executeJournalStep(root, workflowRun.runId, "release-back-merge", () => backMergeRootProductionIntoStaging(root, true));
3672
3725
  const devTagCleanupMode = effectiveInput.devTagCleanup ?? "safe-after-release";
3673
3726
  const devTagCleanup = devTagCleanupMode === "off" ? (skipJournalStep(root, workflowRun.runId, "cleanup-dev-tags", { status: "skipped", reason: "disabled" }), { status: "skipped", reason: "disabled" }) : await executeJournalStep(root, workflowRun.runId, "cleanup-dev-tags", () => {
@@ -3740,13 +3793,29 @@ async function workflowRelease(helpers, input) {
3740
3793
  });
3741
3794
  } catch (error) {
3742
3795
  ensureWorkflowWorkspaceLinks(root, helpers, effectiveInput.workspaceLinks ?? "auto");
3796
+ const latestJournal = readWorkflowRunJournal(root, workflowRun.runId);
3797
+ const lastCompleted = [...latestJournal?.steps ?? []].reverse().find((step) => step.status === "completed") ?? null;
3798
+ const nextPending = latestJournal?.steps.find((step) => step.status === "pending") ?? null;
3799
+ helpers.write(`[release][recovery] Last release phase: ${lastCompleted?.id ?? "not-started"}; next phase: ${nextPending?.id ?? "none"}.`, "stderr");
3800
+ try {
3801
+ const repair = reattachRepairablePackageRepos(root, [STAGING_BRANCH, PRODUCTION_BRANCH], {
3802
+ onProgress: (line, stream) => helpers.write(line, stream)
3803
+ });
3804
+ if (repair.blockers.length > 0) {
3805
+ helpers.write(`[release][recovery] Package repos need manual review before retrying:
3806
+ ${repair.blockers.join("\n")}`, "stderr");
3807
+ }
3808
+ } catch (repairError) {
3809
+ helpers.write(`[release][recovery] Package repo repair failed: ${repairError instanceof Error ? repairError.message : String(repairError)}`, "stderr");
3810
+ }
3811
+ helpers.write(`Safe recovery: npx trsd release --${level} --json, or inspect with npx trsd recover --json.`, "stderr");
3743
3812
  failWorkflowRun(root, workflowRun.runId, error, {
3744
3813
  resumable: true,
3745
3814
  runId: workflowRun.runId,
3746
3815
  command: "release",
3747
- message: `Resume the interrupted release on ${STAGING_BRANCH}.`,
3748
- recoverCommand: "treeseed recover",
3749
- resumeCommand: `treeseed resume ${workflowRun.runId}`
3816
+ message: `Resume the interrupted release on ${STAGING_BRANCH}. Last phase: ${lastCompleted?.id ?? "not-started"}; next phase: ${nextPending?.id ?? "none"}.`,
3817
+ recoverCommand: "npx trsd recover --json",
3818
+ resumeCommand: `npx trsd release --${level} --json`
3750
3819
  });
3751
3820
  throw error;
3752
3821
  }
@@ -93,8 +93,11 @@ export type TreeseedWorkflowState = {
93
93
  aligned: boolean;
94
94
  localBranch: boolean;
95
95
  remoteBranch: boolean;
96
+ detached: boolean;
97
+ detachedRepair: Record<string, unknown> | null;
96
98
  }>;
97
99
  blockers: string[];
100
+ warnings: string[];
98
101
  };
99
102
  preview: {
100
103
  enabled: boolean;
@@ -24,6 +24,7 @@ import { collectCliPreflight } from "./operations/services/workspace-preflight.j
24
24
  import { currentBranch, gitStatusPorcelain } from "./operations/services/workspace-save.js";
25
25
  import { hasCompleteTreeseedPackageCheckout, isWorkspaceRoot, run, workspacePackages } from "./operations/services/workspace-tools.js";
26
26
  import { inspectWorkspaceDependencyMode } from "./operations/services/workspace-dependency-mode.js";
27
+ import { inspectDetachedHeadRepair, PRODUCTION_BRANCH, STAGING_BRANCH } from "./operations/services/git-workflow.js";
27
28
  import { classifyWorkflowRunJournals, inspectWorkflowLock } from "./workflow/runs.js";
28
29
  import { createTreeseedManagedToolEnv, resolveTreeseedToolCommand } from "./managed-dependencies.js";
29
30
  import {
@@ -306,6 +307,7 @@ function resolveTreeseedWorkflowState(cwd, options = {}) {
306
307
  const repoBranch = currentBranch(pkg.dir) || null;
307
308
  const dirty = gitStatusPorcelain(pkg.dir).length > 0;
308
309
  const expectedBranch = branchName;
310
+ const detachedRepair = repoBranch ? null : inspectDetachedHeadRepair(pkg.dir, [expectedBranch, STAGING_BRANCH, PRODUCTION_BRANCH].filter((branch) => Boolean(branch)));
309
311
  let localBranch = false;
310
312
  if (expectedBranch) {
311
313
  if (repoBranch === expectedBranch) {
@@ -327,14 +329,35 @@ function resolveTreeseedWorkflowState(cwd, options = {}) {
327
329
  dirty,
328
330
  aligned: expectedBranch ? repoBranch === expectedBranch : true,
329
331
  localBranch,
330
- remoteBranch
332
+ remoteBranch,
333
+ detached: repoBranch == null,
334
+ detachedRepair: detachedRepair ? {
335
+ repairable: detachedRepair.repairable,
336
+ targetBranch: detachedRepair.targetBranch,
337
+ headSha: detachedRepair.headSha,
338
+ targetSha: detachedRepair.targetSha,
339
+ dirty: detachedRepair.dirty,
340
+ blocker: detachedRepair.blocker
341
+ } : null
331
342
  };
332
343
  }) : [];
333
344
  const packageSyncBlockers = [];
345
+ const packageSyncWarnings = [];
334
346
  for (const repo of packageSyncRepos) {
335
347
  if (repo.dirty) {
336
348
  packageSyncBlockers.push(`${repo.name} has uncommitted changes.`);
337
349
  }
350
+ const detachedRepair = repo.detachedRepair;
351
+ if (repo.detached && detachedRepair?.repairable === true) {
352
+ const targetBranch = typeof detachedRepair.targetBranch === "string" ? detachedRepair.targetBranch : branchName;
353
+ const dirtyNote = detachedRepair.dirty === true ? " with local changes preserved" : "";
354
+ packageSyncWarnings.push(`${repo.name} is detached at ${targetBranch ?? "an expected branch"} HEAD${dirtyNote}; workflow commands can reattach it automatically.`);
355
+ continue;
356
+ }
357
+ if (repo.detached && detachedRepair?.repairable !== true) {
358
+ packageSyncBlockers.push(`${repo.name} is detached at a commit that does not match ${branchName ?? STAGING_BRANCH}/${PRODUCTION_BRANCH}; review manually.`);
359
+ continue;
360
+ }
338
361
  if (branchName && !repo.localBranch && !repo.remoteBranch) {
339
362
  packageSyncBlockers.push(`${repo.name} is missing branch ${branchName}.`);
340
363
  continue;
@@ -424,7 +447,8 @@ function resolveTreeseedWorkflowState(cwd, options = {}) {
424
447
  aligned: packageSyncRepos.every((repo) => repo.aligned),
425
448
  dirty: packageSyncRepos.some((repo) => repo.dirty),
426
449
  repos: packageSyncRepos,
427
- blockers: packageSyncBlockers
450
+ blockers: packageSyncBlockers,
451
+ warnings: packageSyncWarnings
428
452
  },
429
453
  preview: {
430
454
  enabled: false,
@@ -707,6 +731,13 @@ function recommendTreeseedNextSteps(state) {
707
731
  return recommendations.slice(0, 3);
708
732
  }
709
733
  if (state.branchRole === "staging") {
734
+ if (state.packageSync.mode === "recursive-workspace" && state.packageSync.warnings.length > 0 && state.branchName) {
735
+ recommendations.push({
736
+ operation: "release",
737
+ reason: "Reattach repairable package repos automatically before continuing the release.",
738
+ input: { bump: "patch" }
739
+ });
740
+ }
710
741
  if (state.packageSync.mode === "recursive-workspace" && state.packageSync.blockers.length > 0 && state.branchName) {
711
742
  recommendations.push({
712
743
  operation: "switch",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@treeseed/sdk",
3
- "version": "0.6.21",
3
+ "version": "0.6.23",
4
4
  "description": "Shared Treeseed SDK for content-backed and D1-backed object models.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {