@treeseed/cli 0.4.11 → 0.5.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 (40) hide show
  1. package/README.md +35 -8
  2. package/dist/cli/handlers/auth-login.js +58 -46
  3. package/dist/cli/handlers/auth-logout.js +23 -11
  4. package/dist/cli/handlers/auth-whoami.js +27 -16
  5. package/dist/cli/handlers/close.js +9 -4
  6. package/dist/cli/handlers/config-ui.d.ts +65 -9
  7. package/dist/cli/handlers/config-ui.js +561 -175
  8. package/dist/cli/handlers/config.js +164 -10
  9. package/dist/cli/handlers/destroy.js +3 -2
  10. package/dist/cli/handlers/dev.js +6 -1
  11. package/dist/cli/handlers/doctor.js +82 -47
  12. package/dist/cli/handlers/recover.d.ts +2 -0
  13. package/dist/cli/handlers/recover.js +25 -0
  14. package/dist/cli/handlers/release.js +14 -4
  15. package/dist/cli/handlers/resume.d.ts +2 -0
  16. package/dist/cli/handlers/resume.js +23 -0
  17. package/dist/cli/handlers/save.js +9 -3
  18. package/dist/cli/handlers/secret-prompts.d.ts +2 -0
  19. package/dist/cli/handlers/secret-prompts.js +54 -0
  20. package/dist/cli/handlers/secrets.d.ts +7 -0
  21. package/dist/cli/handlers/secrets.js +161 -0
  22. package/dist/cli/handlers/stage.js +10 -4
  23. package/dist/cli/handlers/status.js +37 -5
  24. package/dist/cli/handlers/switch.js +11 -4
  25. package/dist/cli/handlers/tasks.js +1 -0
  26. package/dist/cli/handlers/utils.js +15 -8
  27. package/dist/cli/handlers/workflow.js +74 -3
  28. package/dist/cli/help-ui.js +1 -1
  29. package/dist/cli/operations-registry.js +200 -21
  30. package/dist/cli/registry.d.ts +8 -0
  31. package/dist/cli/registry.js +19 -1
  32. package/dist/cli/repair.js +5 -9
  33. package/dist/cli/ui/framework.d.ts +2 -0
  34. package/dist/cli/ui/framework.js +53 -22
  35. package/dist/cli/ui/mouse.d.ts +3 -1
  36. package/dist/cli/ui/mouse.js +3 -3
  37. package/package.json +7 -6
  38. package/scripts/verify-driver.mjs +34 -0
  39. package/dist/cli/workflow-state.d.ts +0 -58
  40. package/dist/cli/workflow-state.js +0 -195
@@ -1,10 +1,14 @@
1
1
  import {
2
+ applyTreeseedConfigValues,
2
3
  applyTreeseedSafeRepairs,
3
4
  collectTreeseedConfigContext,
5
+ ensureTreeseedActVerificationTooling,
6
+ ensureTreeseedSecretSessionForConfig,
4
7
  findNearestTreeseedRoot
5
8
  } from "@treeseed/sdk/workflow-support";
6
9
  import { fail, guidedResult } from "./utils.js";
7
10
  import { buildCliConfigPages, runCliConfigEditor } from "./config-ui.js";
11
+ import { promptForNewPassphrase, promptHidden } from "./secret-prompts.js";
8
12
  import { createWorkflowSdk, renderWorkflowNextSteps, workflowErrorResult } from "./workflow.js";
9
13
  function normalizeConfigScopes(value) {
10
14
  const requested = Array.isArray(value) ? value.map(String) : typeof value === "string" ? [value] : ["all"];
@@ -31,11 +35,85 @@ function formatPrintEnvReports(payload) {
31
35
  }
32
36
  return lines.filter((line, index, all) => !(line === "" && all[index - 1] === ""));
33
37
  }
38
+ function resolveMouseEnabled(value, env) {
39
+ if (value === true) {
40
+ return true;
41
+ }
42
+ const envValue = env.TREESEED_UI_MOUSE;
43
+ return envValue === "1" || envValue === "true";
44
+ }
45
+ function configReadinessLabel(readiness) {
46
+ if (!readiness) {
47
+ return "pending";
48
+ }
49
+ if (typeof readiness.phase === "string" && readiness.phase.length > 0) {
50
+ return readiness.phase;
51
+ }
52
+ if (readiness.deployable) {
53
+ return "deployable";
54
+ }
55
+ if (readiness.provisioned) {
56
+ return "provisioned";
57
+ }
58
+ if (readiness.configured) {
59
+ return "config_complete";
60
+ }
61
+ return "pending";
62
+ }
63
+ function describeSecretBootstrap(secretSession) {
64
+ if (!secretSession?.status) {
65
+ return "(unknown)";
66
+ }
67
+ if (secretSession.createdWrappedKey) {
68
+ return secretSession.unlockSource === "env" ? "created via env passphrase" : "created via interactive passphrase";
69
+ }
70
+ if (secretSession.migratedWrappedKey) {
71
+ return secretSession.unlockSource === "env" ? "migrated via env passphrase" : "migrated via interactive passphrase";
72
+ }
73
+ if (secretSession.unlockSource === "existing-session") {
74
+ return secretSession.status.unlocked ? "already unlocked" : "locked";
75
+ }
76
+ return secretSession.unlockSource === "env" ? "unlocked via env passphrase" : "unlocked interactively";
77
+ }
78
+ function describeInteractiveSecretBootstrap(secretSession) {
79
+ if (!secretSession?.status) {
80
+ return void 0;
81
+ }
82
+ if (secretSession.createdWrappedKey) {
83
+ return "Wrapped machine key created and unlocked.";
84
+ }
85
+ if (secretSession.migratedWrappedKey) {
86
+ return "Legacy machine key wrapped and unlocked.";
87
+ }
88
+ if (secretSession.unlockSource === "interactive") {
89
+ return "Wrapped machine key unlocked.";
90
+ }
91
+ if (secretSession.unlockSource === "env") {
92
+ return "Wrapped machine key unlocked from TREESEED_KEY_PASSPHRASE.";
93
+ }
94
+ return void 0;
95
+ }
96
+ function describeSharedStorageMigrations(migrations) {
97
+ if (!Array.isArray(migrations) || migrations.length === 0) {
98
+ return void 0;
99
+ }
100
+ return migrations.map((migration) => {
101
+ const label = typeof migration.label === "string" && migration.label.length > 0 ? migration.label : migration.entryId;
102
+ const promotedFrom = typeof migration.promotedFrom === "string" ? migration.promotedFrom : "staging";
103
+ const scopes = Array.isArray(migration.consolidatedScopes) && migration.consolidatedScopes.length > 0 ? migration.consolidatedScopes.join("/") : promotedFrom;
104
+ return migration.hadConflicts ? `${label} consolidated from ${scopes} using ${promotedFrom}` : `${label} promoted from ${promotedFrom}`;
105
+ }).join("; ");
106
+ }
34
107
  function renderConfigResult(commandName, result) {
35
108
  const payload = result.payload;
36
109
  const toolHealth = payload.toolHealth;
110
+ const configContext = payload.context;
111
+ const configReadiness = configContext?.configReadinessByScope?.local ?? {};
37
112
  const readinessByScope = payload.result?.readinessByScope ?? {};
38
- const summary = payload.mode === "print-env-only" ? "Treeseed config environment report completed." : payload.mode === "rotate-machine-key" ? "Treeseed machine key rotated successfully." : "Treeseed config completed successfully.";
113
+ const secretSession = payload.secretSession;
114
+ const sharedStorageMigrations = payload.result?.sharedStorageMigrations;
115
+ const summary = payload.mode === "print-env-only" ? "Treeseed config environment report completed." : payload.mode === "rotate-machine-key" ? "Treeseed machine key rotated successfully." : payload.mode === "connect-market" ? "Knowledge Coop pairing completed successfully." : "Treeseed config completed successfully.";
116
+ const market = payload.market;
39
117
  return guidedResult({
40
118
  command: commandName,
41
119
  summary,
@@ -46,13 +124,28 @@ function renderConfigResult(commandName, result) {
46
124
  { label: "Safe repairs", value: Array.isArray(payload.repairs) ? payload.repairs.length : 0 },
47
125
  { label: "Machine config", value: payload.configPath },
48
126
  { label: "Machine key", value: payload.keyPath },
49
- { label: "Local readiness", value: readinessByScope.local?.deployable ? "deployable" : readinessByScope.local?.configured ? "configured" : "pending" },
50
- { label: "Staging readiness", value: readinessByScope.staging?.deployable ? "deployable" : readinessByScope.staging?.provisioned ? "provisioned" : readinessByScope.staging?.configured ? "configured" : "pending" },
51
- { label: "Prod readiness", value: readinessByScope.prod?.deployable ? "deployable" : readinessByScope.prod?.provisioned ? "provisioned" : readinessByScope.prod?.configured ? "configured" : "pending" },
127
+ { label: "Secrets session", value: describeSecretBootstrap(secretSession) },
128
+ { label: "Shared consolidations", value: describeSharedStorageMigrations(sharedStorageMigrations) },
129
+ { label: "Local readiness", value: configReadinessLabel(readinessByScope.local) },
130
+ { label: "Staging readiness", value: configReadinessLabel(readinessByScope.staging) },
131
+ { label: "Prod readiness", value: configReadinessLabel(readinessByScope.prod) },
132
+ { label: "GitHub token/config", value: configReadiness.github?.configured ? "configured" : "missing" },
133
+ { label: "Cloudflare token/config", value: configReadiness.cloudflare?.configured ? "configured" : "missing" },
134
+ { label: "Railway token/config", value: configReadiness.railway?.configured ? "configured" : "missing" },
52
135
  { label: "GitHub CLI", value: toolHealth?.githubCli?.available ? "ready" : "missing" },
136
+ { label: "Wrangler CLI", value: toolHealth?.wranglerCli?.available ? "ready" : "missing" },
137
+ { label: "Railway CLI", value: toolHealth?.railwayCli?.available ? "ready" : "missing" },
53
138
  { label: "gh act", value: toolHealth?.ghActExtension?.available ? "ready" : "missing" },
54
139
  { label: "Docker", value: toolHealth?.dockerDaemon?.available ? "ready" : "missing" },
55
- { label: "ACT verify", value: toolHealth?.actVerificationReady ? "ready" : "not ready" }
140
+ { label: "ACT verify", value: toolHealth?.actVerificationReady ? "ready" : "not ready" },
141
+ ...market ? [
142
+ { label: "Market base URL", value: market.baseUrl ?? "(none)" },
143
+ { label: "Market team", value: market.teamSlug ?? market.teamId ?? "(none)" },
144
+ { label: "Market project", value: market.projectSlug ?? market.projectId ?? "(none)" },
145
+ { label: "Hub mode", value: market.hubMode ?? "(unknown)" },
146
+ { label: "Runtime mode", value: market.runtimeMode ?? "(unknown)" },
147
+ { label: "Runtime ready", value: market.runtimeReady ? "yes" : "no" }
148
+ ] : []
56
149
  ],
57
150
  nextSteps: renderWorkflowNextSteps(result),
58
151
  report: payload
@@ -66,35 +159,86 @@ const handleConfig = async (invocation, context) => {
66
159
  });
67
160
  const scopes = normalizeConfigScopes(invocation.args.environment);
68
161
  const sync = invocation.args.sync;
69
- const interactive = context.outputFormat !== "json" && process.stdin.isTTY && process.stdout.isTTY;
70
- if (interactive && invocation.args.printEnvOnly !== true && invocation.args.rotateMachineKey !== true) {
162
+ const interactive = context.outputFormat !== "json" && context.interactiveUi !== false && process.stdin.isTTY && process.stdout.isTTY;
163
+ const nonInteractive = invocation.args.nonInteractive === true || context.outputFormat === "json";
164
+ const operationalMode = invocation.args.printEnvOnly === true || invocation.args.rotateMachineKey === true || invocation.args.connectMarket === true;
165
+ if (!interactive && !nonInteractive && !operationalMode) {
166
+ return fail("Treeseed config requires a TTY for the interactive editor. Re-run in a terminal, or use --non-interactive, --json, --print-env-only, --rotate-machine-key, or --connect-market.");
167
+ }
168
+ if (interactive && !nonInteractive && !operationalMode) {
71
169
  const tenantRoot = findNearestTreeseedRoot(context.cwd) ?? context.cwd;
72
170
  if (!tenantRoot) {
73
171
  return fail("Treeseed config requires a Treeseed project. Run the command from inside a tenant or initialize one first.");
74
172
  }
75
173
  applyTreeseedSafeRepairs(tenantRoot);
174
+ const toolAvailability = ensureTreeseedActVerificationTooling({
175
+ tenantRoot,
176
+ installIfMissing: invocation.args.installMissingTooling === true,
177
+ env: context.env,
178
+ write: context.write
179
+ });
180
+ const secretSession = await ensureTreeseedSecretSessionForConfig({
181
+ tenantRoot,
182
+ interactive: true,
183
+ env: context.env,
184
+ promptForPassphrase: () => promptHidden("Treeseed passphrase: "),
185
+ promptForNewPassphrase
186
+ });
76
187
  const configContext = collectTreeseedConfigContext({
77
188
  tenantRoot,
78
189
  scopes,
79
190
  env: context.env
80
191
  });
192
+ const initialViewMode = (() => {
193
+ if (invocation.args.full === true) {
194
+ return "full";
195
+ }
196
+ return buildCliConfigPages(configContext, "local", {}, "startup").length > 0 ? "startup" : "full";
197
+ })();
81
198
  const editorResult = await runCliConfigEditor(configContext, {
82
- initialViewMode: invocation.args.full === true ? "full" : "startup"
199
+ initialViewMode,
200
+ mouseEnabled: resolveMouseEnabled(invocation.args.mouse, context.env),
201
+ initialStatusMessage: describeInteractiveSecretBootstrap(secretSession),
202
+ toolAvailability,
203
+ secretSession,
204
+ onCommit: async (update) => {
205
+ applyTreeseedConfigValues({
206
+ tenantRoot,
207
+ updates: [{
208
+ scope: update.scope,
209
+ entryId: update.entryId,
210
+ value: update.value,
211
+ reused: false
212
+ }]
213
+ });
214
+ return collectTreeseedConfigContext({
215
+ tenantRoot,
216
+ scopes,
217
+ env: context.env
218
+ });
219
+ }
83
220
  });
84
221
  if (editorResult === null) {
85
222
  return fail("Treeseed config canceled.");
86
223
  }
87
- const updates = buildCliConfigPages(configContext, "all", editorResult.overrides, "full").map((page) => ({
224
+ const refreshedContext = collectTreeseedConfigContext({
225
+ tenantRoot,
226
+ scopes,
227
+ env: context.env
228
+ });
229
+ const updates = refreshedContext.scopes.flatMap((scope) => buildCliConfigPages(refreshedContext, scope, editorResult.overrides, "full")).filter((page, index, allPages) => allPages.findIndex((candidate) => candidate.key === page.key) === index).map((page) => ({
88
230
  scope: page.scope,
89
231
  entryId: page.entry.id,
90
232
  value: page.finalValue,
91
233
  reused: !(page.key in editorResult.overrides)
92
234
  }));
235
+ context.write("Applying config updates, validating environments, and syncing managed providers...", "stdout");
93
236
  const result2 = await workflow.config({
94
237
  environment: scopes,
95
238
  sync,
96
239
  printEnv: invocation.args.printEnv === true,
97
240
  showSecrets: invocation.args.showSecrets === true,
241
+ installMissingTooling: invocation.args.installMissingTooling === true,
98
242
  nonInteractive: true,
99
243
  updates
100
244
  });
@@ -107,7 +251,17 @@ const handleConfig = async (invocation, context) => {
107
251
  printEnvOnly: invocation.args.printEnvOnly === true,
108
252
  showSecrets: invocation.args.showSecrets === true,
109
253
  rotateMachineKey: invocation.args.rotateMachineKey === true,
110
- nonInteractive: context.outputFormat === "json"
254
+ connectMarket: invocation.args.connectMarket === true,
255
+ marketBaseUrl: typeof invocation.args.marketBaseUrl === "string" ? invocation.args.marketBaseUrl : void 0,
256
+ marketTeamId: typeof invocation.args.marketTeamId === "string" ? invocation.args.marketTeamId : void 0,
257
+ marketTeamSlug: typeof invocation.args.marketTeamSlug === "string" ? invocation.args.marketTeamSlug : void 0,
258
+ marketProjectId: typeof invocation.args.marketProjectId === "string" ? invocation.args.marketProjectId : void 0,
259
+ marketProjectSlug: typeof invocation.args.marketProjectSlug === "string" ? invocation.args.marketProjectSlug : void 0,
260
+ marketProjectApiBaseUrl: typeof invocation.args.marketProjectApiBaseUrl === "string" ? invocation.args.marketProjectApiBaseUrl : void 0,
261
+ marketAccessToken: typeof invocation.args.marketAccessToken === "string" ? invocation.args.marketAccessToken : void 0,
262
+ rotateRunnerToken: invocation.args.rotateRunnerToken === true,
263
+ installMissingTooling: invocation.args.installMissingTooling === true,
264
+ nonInteractive
111
265
  });
112
266
  if (context.outputFormat !== "json" && result.payload.mode === "print-env-only") {
113
267
  return {
@@ -24,6 +24,7 @@ const handleDestroy = async (invocation, context) => {
24
24
  }
25
25
  }).destroy({
26
26
  environment: String(invocation.args.environment),
27
+ plan: invocation.args.plan === true || invocation.args.dryRun === true,
27
28
  dryRun: invocation.args.dryRun === true,
28
29
  force: invocation.args.force === true,
29
30
  removeBuildArtifacts: invocation.args.removeBuildArtifacts === true
@@ -31,14 +32,14 @@ const handleDestroy = async (invocation, context) => {
31
32
  const payload = result.payload;
32
33
  return guidedResult({
33
34
  command: invocation.commandName || "destroy",
34
- summary: payload.dryRun ? "Treeseed destroy dry run completed." : "Treeseed destroy completed successfully.",
35
+ summary: result.executionMode === "plan" ? "Treeseed destroy plan ready." : "Treeseed destroy completed successfully.",
35
36
  facts: [
36
37
  { label: "Environment", value: payload.scope },
37
38
  { label: "Dry run", value: payload.dryRun ? "yes" : "no" },
38
39
  { label: "Removed build artifacts", value: payload.removeBuildArtifacts ? "yes" : "no" }
39
40
  ],
40
41
  nextSteps: renderWorkflowNextSteps(result),
41
- report: payload
42
+ report: result
42
43
  });
43
44
  } catch (error) {
44
45
  return workflowErrorResult(error);
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import { dirname, resolve } from "node:path";
4
+ import { resolveTreeseedLaunchEnvironment } from "@treeseed/sdk/workflow-support";
4
5
  import { workflowErrorResult } from "./workflow.js";
5
6
  const require2 = createRequire(import.meta.url);
6
7
  function resolveCoreDevEntrypoint(cwd) {
@@ -46,7 +47,11 @@ const handleDev = async (invocation, context) => {
46
47
  const args = watch ? [...resolved.args, "--watch"] : resolved.args;
47
48
  const result = context.spawn(resolved.command, args, {
48
49
  cwd: context.cwd,
49
- env: context.env,
50
+ env: resolveTreeseedLaunchEnvironment({
51
+ tenantRoot: context.cwd,
52
+ scope: "local",
53
+ baseEnv: { ...process.env, ...context.env ?? {} }
54
+ }),
50
55
  stdio: "inherit"
51
56
  });
52
57
  return {
@@ -1,54 +1,89 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
1
3
  import { collectCliPreflight } from "@treeseed/sdk/workflow-support";
2
4
  import { guidedResult } from "./utils.js";
3
- import { resolveTreeseedWorkflowState } from "../workflow-state.js";
4
5
  import { applyTreeseedSafeRepairs } from "../repair.js";
5
- const handleDoctor = (invocation, context) => {
6
- const performedFixes = invocation.args.fix === true && resolveTreeseedWorkflowState(context.cwd).deployConfigPresent ? applyTreeseedSafeRepairs(context.cwd) : [];
7
- const state = resolveTreeseedWorkflowState(context.cwd);
8
- const preflight = collectCliPreflight({ cwd: context.cwd, requireAuth: false });
9
- const railwayManagedServicesEnabled = Object.values(state.managedServices).some((service) => service.enabled);
10
- const mustFixNow = [];
11
- const optional = [];
12
- if (!state.workspaceRoot) mustFixNow.push("Run Treeseed inside a Treeseed workspace so package commands and workflow state can resolve correctly.");
13
- if (!state.repoRoot) mustFixNow.push("Initialize or clone the git repository before using save, close, stage, or release flows.");
14
- if (!state.deployConfigPresent) mustFixNow.push("Create or restore treeseed.site.yaml so the tenant contract can be loaded.");
15
- if (preflight.missingCommands.includes("git")) mustFixNow.push("Install Git.");
16
- if (preflight.missingCommands.includes("npm")) mustFixNow.push("Install npm 10 or newer.");
17
- if (!state.files.machineConfig) mustFixNow.push("Run `treeseed config --environment local` to create the local machine config.");
18
- if (!state.files.envLocal) optional.push("Create `.env.local` or run `treeseed config --environment local` to generate it.");
19
- if (!state.files.devVars) optional.push("Generate `.dev.vars` by running `treeseed config --environment local`.");
20
- if (!state.auth.gh) optional.push("Configure `GH_TOKEN` for GitHub CLI automation and Copilot-backed workflows.");
21
- if (!state.auth.wrangler) optional.push("Configure `CLOUDFLARE_API_TOKEN` before staging, preview, or production deployment work.");
22
- if (!state.auth.railway && railwayManagedServicesEnabled) {
23
- optional.push("Configure `RAILWAY_API_TOKEN` before deploying the managed Railway services.");
6
+ import { createWorkflowSdk, workflowErrorResult } from "./workflow.js";
7
+ const handleDoctor = async (invocation, context) => {
8
+ try {
9
+ const status = await createWorkflowSdk(context).status();
10
+ const state = status.payload;
11
+ const performedFixes = invocation.args.fix === true && state.deployConfigPresent ? applyTreeseedSafeRepairs(state.cwd) : [];
12
+ const preflight = collectCliPreflight({ cwd: context.cwd, requireAuth: false });
13
+ const railwayManagedServicesEnabled = Object.values(state.managedServices).some((service) => service.enabled);
14
+ const mustFixNow = [];
15
+ const optional = [];
16
+ if (!state.workspaceRoot) mustFixNow.push("Run Treeseed inside a Treeseed workspace so package commands and workflow state can resolve correctly.");
17
+ if (!state.repoRoot) mustFixNow.push("Initialize or clone the git repository before using save, close, stage, or release flows.");
18
+ if (!state.deployConfigPresent) mustFixNow.push("Create or restore treeseed.site.yaml so the tenant contract can be loaded.");
19
+ if (preflight.missingCommands.includes("git")) mustFixNow.push("Install Git.");
20
+ if (preflight.missingCommands.includes("npm")) mustFixNow.push("Install npm 10 or newer.");
21
+ if (!state.files.machineConfig) mustFixNow.push("Run `treeseed config --environment local` to create the local machine config.");
22
+ if (!state.secrets.wrappedKeyPresent) mustFixNow.push("Run `treeseed secrets:unlock` to create the wrapped machine key used for local secret storage.");
23
+ if (state.secrets.migrationRequired) mustFixNow.push("Run `treeseed secrets:migrate-key` to replace the legacy plaintext machine key with the wrapped format.");
24
+ if (state.packageSync.blockers.length > 0) mustFixNow.push(...state.packageSync.blockers);
25
+ if (state.workflowControl.lock.active && state.workflowControl.lock.runId) {
26
+ mustFixNow.push(`Active workflow lock detected for run ${state.workflowControl.lock.runId}. Use \`treeseed recover\` before starting another mutating command.`);
27
+ }
28
+ if (state.workflowControl.interruptedRuns.length > 0) {
29
+ mustFixNow.push(`Interrupted workflow runs detected. Resume the latest run with \`treeseed resume ${state.workflowControl.interruptedRuns[0].runId}\` or inspect \`treeseed recover\`.`);
30
+ }
31
+ if (state.packageSync.completeCheckout) {
32
+ for (const repo of state.packageSync.repos) {
33
+ const publishWorkflowPath = resolve(state.cwd, repo.path, ".github", "workflows", "publish.yml");
34
+ if (!existsSync(publishWorkflowPath)) {
35
+ mustFixNow.push(`${repo.name} is missing .github/workflows/publish.yml required for recursive release.`);
36
+ }
37
+ }
38
+ }
39
+ for (const workflowName of ["verify.yml", "deploy.yml"]) {
40
+ const workflowPath = resolve(state.cwd, ".github", "workflows", workflowName);
41
+ if (!existsSync(workflowPath)) {
42
+ mustFixNow.push(`Missing root workflow contract .github/workflows/${workflowName}.`);
43
+ }
44
+ }
45
+ if (!state.auth.gh) optional.push("Configure GitHub token/config (`GH_TOKEN`) for GitHub CLI automation and Copilot-backed workflows.");
46
+ if (!state.auth.wrangler) optional.push("Configure Cloudflare token/config (`CLOUDFLARE_API_TOKEN`) before staging, preview, or production deployment work.");
47
+ if (!state.auth.railway && railwayManagedServicesEnabled) {
48
+ optional.push("Configure Railway token/config (`RAILWAY_API_TOKEN`) before deploying the managed Railway services.");
49
+ }
50
+ if (!state.auth.remoteApi && state.managedServices.api.enabled) {
51
+ optional.push("Run `treeseed auth:login` so the CLI can use the configured remote API.");
52
+ }
53
+ if (state.secrets.wrappedKeyPresent && !state.secrets.keyAgentUnlocked) {
54
+ optional.push("Run `treeseed secrets:unlock` before starting secret-backed dev, deploy, or runner commands.");
55
+ }
56
+ if (!state.secrets.keyAgentRunning) {
57
+ optional.push("The Treeseed key-agent is not running yet. It will start automatically when you unlock the secret session.");
58
+ }
59
+ if (!state.auth.copilot) optional.push("Configure `GH_TOKEN` if you rely on local Copilot-assisted workflows.");
60
+ return guidedResult({
61
+ command: "doctor",
62
+ summary: mustFixNow.length === 0 ? "Treeseed doctor found no blocking issues." : "Treeseed doctor found issues that need attention.",
63
+ facts: [
64
+ { label: "Must fix now", value: mustFixNow.length },
65
+ { label: "Optional follow-up", value: optional.length },
66
+ { label: "Safe fixes applied", value: performedFixes.length },
67
+ { label: "Branch", value: state.branchName ?? "(none)" },
68
+ { label: "Workspace root", value: state.workspaceRoot ? "yes" : "no" }
69
+ ],
70
+ nextSteps: [
71
+ ...mustFixNow.map((item) => item),
72
+ ...mustFixNow.length === 0 ? optional : optional.map((item) => `Optional: ${item}`)
73
+ ],
74
+ report: {
75
+ ...status,
76
+ state,
77
+ preflight,
78
+ performedFixes,
79
+ mustFixNow,
80
+ optional
81
+ },
82
+ exitCode: mustFixNow.length === 0 ? 0 : 1
83
+ });
84
+ } catch (error) {
85
+ return workflowErrorResult(error);
24
86
  }
25
- if (!state.auth.remoteApi && state.managedServices.api.enabled) {
26
- optional.push("Run `treeseed auth:login` so the CLI can use the configured remote API.");
27
- }
28
- if (!state.auth.copilot) optional.push("Configure `GH_TOKEN` if you rely on local Copilot-assisted workflows.");
29
- return guidedResult({
30
- command: "doctor",
31
- summary: mustFixNow.length === 0 ? "Treeseed doctor found no blocking issues." : "Treeseed doctor found issues that need attention.",
32
- facts: [
33
- { label: "Must fix now", value: mustFixNow.length },
34
- { label: "Optional follow-up", value: optional.length },
35
- { label: "Safe fixes applied", value: performedFixes.length },
36
- { label: "Branch", value: state.branchName ?? "(none)" },
37
- { label: "Workspace root", value: state.workspaceRoot ? "yes" : "no" }
38
- ],
39
- nextSteps: [
40
- ...mustFixNow.map((item) => item),
41
- ...mustFixNow.length === 0 ? optional : optional.map((item) => `Optional: ${item}`)
42
- ],
43
- report: {
44
- state,
45
- preflight,
46
- performedFixes,
47
- mustFixNow,
48
- optional
49
- },
50
- exitCode: mustFixNow.length === 0 ? 0 : 1
51
- });
52
87
  };
53
88
  export {
54
89
  handleDoctor
@@ -0,0 +1,2 @@
1
+ import type { TreeseedCommandHandler } from '../types.js';
2
+ export declare const handleRecover: TreeseedCommandHandler;
@@ -0,0 +1,25 @@
1
+ import { guidedResult } from "./utils.js";
2
+ import { createWorkflowSdk, renderWorkflowNextSteps, workflowErrorResult } from "./workflow.js";
3
+ const handleRecover = async (_invocation, context) => {
4
+ try {
5
+ const result = await createWorkflowSdk(context).recover({});
6
+ const payload = result.payload;
7
+ return guidedResult({
8
+ command: "recover",
9
+ summary: payload.interruptedRuns.length > 0 || payload.lock.active ? "Treeseed recover found workflow state that may need attention." : "Treeseed recover found no active locks or interrupted runs.",
10
+ facts: [
11
+ { label: "Active lock", value: payload.lock.active ? "yes" : "no" },
12
+ { label: "Stale lock", value: payload.lock.stale ? "yes" : "no" },
13
+ { label: "Interrupted runs", value: payload.interruptedRuns.length },
14
+ { label: "Recorded runs", value: payload.runCount }
15
+ ],
16
+ nextSteps: renderWorkflowNextSteps(result),
17
+ report: result
18
+ });
19
+ } catch (error) {
20
+ return workflowErrorResult(error);
21
+ }
22
+ };
23
+ export {
24
+ handleRecover
25
+ };
@@ -3,23 +3,33 @@ import { createWorkflowSdk, renderWorkflowNextSteps, workflowErrorResult } from
3
3
  const handleRelease = async (invocation, context) => {
4
4
  try {
5
5
  const bump = ["major", "minor", "patch"].find((candidate) => invocation.args[candidate] === true) ?? "patch";
6
- const result = await createWorkflowSdk(context).release({ bump });
6
+ const result = await createWorkflowSdk(context).release({
7
+ bump,
8
+ plan: invocation.args.plan === true || invocation.args.dryRun === true,
9
+ dryRun: invocation.args.dryRun === true
10
+ });
7
11
  const payload = result.payload;
12
+ const completedPublishes = payload.publishWait.filter((entry) => entry.status === "completed").length;
8
13
  return guidedResult({
9
14
  command: invocation.commandName || "release",
10
- summary: "Treeseed release completed successfully.",
15
+ summary: result.executionMode === "plan" ? "Treeseed release plan ready." : "Treeseed release completed successfully.",
11
16
  facts: [
17
+ { label: "Mode", value: payload.mode },
12
18
  { label: "Staging branch", value: payload.stagingBranch },
13
19
  { label: "Production branch", value: payload.productionBranch },
20
+ { label: "Merge strategy", value: payload.mergeStrategy },
14
21
  { label: "Release level", value: payload.level },
15
22
  { label: "Root version", value: payload.rootVersion },
16
23
  { label: "Release tag", value: payload.releaseTag },
17
24
  { label: "Released commit", value: payload.releasedCommit.slice(0, 12) },
18
- { label: "Updated packages", value: payload.touchedPackages.length },
25
+ { label: "Changed packages", value: String(payload.packageSelection.changed.length) },
26
+ { label: "Dependent packages", value: String(payload.packageSelection.dependents.length) },
27
+ { label: "Released packages", value: String(payload.touchedPackages.length) },
28
+ { label: "Publish waits", value: String(completedPublishes) },
19
29
  { label: "Final branch", value: payload.finalBranch }
20
30
  ],
21
31
  nextSteps: renderWorkflowNextSteps(result),
22
- report: payload
32
+ report: result
23
33
  });
24
34
  } catch (error) {
25
35
  return workflowErrorResult(error);
@@ -0,0 +1,2 @@
1
+ import type { TreeseedCommandHandler } from '../types.js';
2
+ export declare const handleResume: TreeseedCommandHandler;
@@ -0,0 +1,23 @@
1
+ import { guidedResult } from "./utils.js";
2
+ import { createWorkflowSdk, renderWorkflowNextSteps, workflowErrorResult } from "./workflow.js";
3
+ const handleResume = async (invocation, context) => {
4
+ try {
5
+ const runId = invocation.positionals[0] ?? "";
6
+ const result = await createWorkflowSdk(context).resume({ runId });
7
+ return guidedResult({
8
+ command: invocation.commandName || "resume",
9
+ summary: `Resumed workflow run ${runId}.`,
10
+ facts: [
11
+ { label: "Run", value: result.runId ?? runId },
12
+ { label: "Command", value: result.command }
13
+ ],
14
+ nextSteps: renderWorkflowNextSteps(result),
15
+ report: result
16
+ });
17
+ } catch (error) {
18
+ return workflowErrorResult(error);
19
+ }
20
+ };
21
+ export {
22
+ handleResume
23
+ };
@@ -5,22 +5,28 @@ const handleSave = async (invocation, context) => {
5
5
  const result = await createWorkflowSdk(context).save({
6
6
  message: invocation.positionals.join(" ").trim(),
7
7
  hotfix: invocation.args.hotfix === true,
8
- preview: invocation.args.preview === true
8
+ preview: invocation.args.preview === true,
9
+ plan: invocation.args.plan === true || invocation.args.dryRun === true,
10
+ dryRun: invocation.args.dryRun === true
9
11
  });
10
12
  const payload = result.payload;
13
+ const savedRepos = (payload.repos ?? []).filter((repo) => repo.committed || repo.pushed).map((repo) => `${repo.name}@${String(repo.commitSha ?? "").slice(0, 12)}`).join(", ");
11
14
  return guidedResult({
12
15
  command: invocation.commandName || "save",
13
- summary: payload.noChanges ? "Treeseed save found no new changes and confirmed branch sync." : "Treeseed save completed successfully.",
16
+ summary: result.executionMode === "plan" ? "Treeseed save plan ready." : payload.noChanges ? "Treeseed save found no new changes and confirmed branch sync." : "Treeseed save completed successfully.",
14
17
  facts: [
18
+ { label: "Mode", value: payload.mode },
15
19
  { label: "Branch", value: payload.branch },
16
20
  { label: "Environment scope", value: payload.scope },
17
21
  { label: "Hotfix", value: payload.hotfix ? "yes" : "no" },
18
22
  { label: "Commit", value: payload.commitSha.slice(0, 12) },
19
23
  { label: "Commit created", value: payload.commitCreated ? "yes" : "no" },
24
+ { label: "Workspace repos", value: savedRepos || ((payload.repos ?? []).length > 0 ? "none saved" : "not applicable") },
25
+ { label: "Market pushed", value: payload.rootRepo?.pushed ? "yes" : "no" },
20
26
  { label: "Preview action", value: payload.previewAction?.status ?? "skipped" }
21
27
  ],
22
28
  nextSteps: renderWorkflowNextSteps(result),
23
- report: payload
29
+ report: result
24
30
  });
25
31
  } catch (error) {
26
32
  return workflowErrorResult(error);
@@ -0,0 +1,2 @@
1
+ export declare function promptHidden(question: string): Promise<string>;
2
+ export declare function promptForNewPassphrase(): Promise<string>;
@@ -0,0 +1,54 @@
1
+ function promptHidden(question) {
2
+ return new Promise((resolvePromise) => {
3
+ const stdin = process.stdin;
4
+ const stdout = process.stdout;
5
+ let value = "";
6
+ function cleanup() {
7
+ stdin.removeListener("data", onData);
8
+ if (stdin.isTTY) {
9
+ stdin.setRawMode(false);
10
+ }
11
+ stdout.write("\n");
12
+ }
13
+ function onData(chunk) {
14
+ const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
15
+ for (const char of text) {
16
+ if (char === "\n" || char === "\r") {
17
+ cleanup();
18
+ resolvePromise(value);
19
+ return;
20
+ }
21
+ if (char === "") {
22
+ cleanup();
23
+ process.exit(1);
24
+ }
25
+ if (char === "\x7F") {
26
+ value = value.slice(0, -1);
27
+ continue;
28
+ }
29
+ value += char;
30
+ }
31
+ }
32
+ stdout.write(question);
33
+ if (stdin.isTTY) {
34
+ stdin.setRawMode(true);
35
+ }
36
+ stdin.resume();
37
+ stdin.on("data", onData);
38
+ });
39
+ }
40
+ async function promptForNewPassphrase() {
41
+ const passphrase = (await promptHidden("New Treeseed passphrase: ")).trim();
42
+ if (!passphrase) {
43
+ throw new Error("A non-empty passphrase is required.");
44
+ }
45
+ const confirmation = (await promptHidden("Confirm passphrase: ")).trim();
46
+ if (passphrase !== confirmation) {
47
+ throw new Error("The passphrase confirmation did not match.");
48
+ }
49
+ return passphrase;
50
+ }
51
+ export {
52
+ promptForNewPassphrase,
53
+ promptHidden
54
+ };
@@ -0,0 +1,7 @@
1
+ import type { TreeseedCommandHandler } from '../types.js';
2
+ export declare const handleSecretsStatus: TreeseedCommandHandler;
3
+ export declare const handleSecretsUnlock: TreeseedCommandHandler;
4
+ export declare const handleSecretsLock: TreeseedCommandHandler;
5
+ export declare const handleSecretsMigrateKey: TreeseedCommandHandler;
6
+ export declare const handleSecretsRotatePassphrase: TreeseedCommandHandler;
7
+ export declare const handleSecretsRotateMachineKey: TreeseedCommandHandler;