@treeseed/cli 0.4.12 → 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.
- package/dist/cli/handlers/auth-login.js +58 -46
- package/dist/cli/handlers/auth-logout.js +23 -11
- package/dist/cli/handlers/auth-whoami.js +27 -16
- package/dist/cli/handlers/config-ui.d.ts +65 -9
- package/dist/cli/handlers/config-ui.js +561 -175
- package/dist/cli/handlers/config.js +164 -10
- package/dist/cli/handlers/dev.js +6 -1
- package/dist/cli/handlers/doctor.js +11 -5
- package/dist/cli/handlers/secret-prompts.d.ts +2 -0
- package/dist/cli/handlers/secret-prompts.js +54 -0
- package/dist/cli/handlers/secrets.d.ts +7 -0
- package/dist/cli/handlers/secrets.js +161 -0
- package/dist/cli/handlers/status.js +31 -5
- package/dist/cli/handlers/workflow.js +31 -0
- package/dist/cli/help-ui.js +1 -1
- package/dist/cli/operations-registry.js +123 -9
- package/dist/cli/registry.d.ts +6 -0
- package/dist/cli/registry.js +15 -1
- package/dist/cli/repair.js +5 -9
- package/dist/cli/ui/framework.d.ts +2 -0
- package/dist/cli/ui/framework.js +53 -22
- package/dist/cli/ui/mouse.d.ts +3 -1
- package/dist/cli/ui/mouse.js +3 -3
- package/package.json +7 -6
- package/scripts/verify-driver.mjs +34 -0
|
@@ -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
|
|
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: "
|
|
50
|
-
{ label: "
|
|
51
|
-
{ label: "
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
package/dist/cli/handlers/dev.js
CHANGED
|
@@ -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:
|
|
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 {
|
|
@@ -19,6 +19,8 @@ const handleDoctor = async (invocation, context) => {
|
|
|
19
19
|
if (preflight.missingCommands.includes("git")) mustFixNow.push("Install Git.");
|
|
20
20
|
if (preflight.missingCommands.includes("npm")) mustFixNow.push("Install npm 10 or newer.");
|
|
21
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.");
|
|
22
24
|
if (state.packageSync.blockers.length > 0) mustFixNow.push(...state.packageSync.blockers);
|
|
23
25
|
if (state.workflowControl.lock.active && state.workflowControl.lock.runId) {
|
|
24
26
|
mustFixNow.push(`Active workflow lock detected for run ${state.workflowControl.lock.runId}. Use \`treeseed recover\` before starting another mutating command.`);
|
|
@@ -40,16 +42,20 @@ const handleDoctor = async (invocation, context) => {
|
|
|
40
42
|
mustFixNow.push(`Missing root workflow contract .github/workflows/${workflowName}.`);
|
|
41
43
|
}
|
|
42
44
|
}
|
|
43
|
-
if (!state.
|
|
44
|
-
if (!state.
|
|
45
|
-
if (!state.auth.gh) optional.push("Configure `GH_TOKEN` for GitHub CLI automation and Copilot-backed workflows.");
|
|
46
|
-
if (!state.auth.wrangler) optional.push("Configure `CLOUDFLARE_API_TOKEN` before staging, preview, or production deployment work.");
|
|
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
47
|
if (!state.auth.railway && railwayManagedServicesEnabled) {
|
|
48
|
-
optional.push("Configure `RAILWAY_API_TOKEN` before deploying the managed Railway services.");
|
|
48
|
+
optional.push("Configure Railway token/config (`RAILWAY_API_TOKEN`) before deploying the managed Railway services.");
|
|
49
49
|
}
|
|
50
50
|
if (!state.auth.remoteApi && state.managedServices.api.enabled) {
|
|
51
51
|
optional.push("Run `treeseed auth:login` so the CLI can use the configured remote API.");
|
|
52
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
|
+
}
|
|
53
59
|
if (!state.auth.copilot) optional.push("Configure `GH_TOKEN` if you rely on local Copilot-assisted workflows.");
|
|
54
60
|
return guidedResult({
|
|
55
61
|
command: "doctor",
|
|
@@ -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;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import {
|
|
2
|
+
inspectTreeseedKeyAgentStatus,
|
|
3
|
+
lockTreeseedSecretSession,
|
|
4
|
+
migrateTreeseedMachineKeyToWrapped,
|
|
5
|
+
rotateTreeseedMachineKey,
|
|
6
|
+
rotateTreeseedMachineKeyPassphrase,
|
|
7
|
+
TREESEED_MACHINE_KEY_PASSPHRASE_ENV,
|
|
8
|
+
TreeseedKeyAgentError,
|
|
9
|
+
unlockTreeseedSecretSessionFromEnv,
|
|
10
|
+
unlockTreeseedSecretSessionInteractive
|
|
11
|
+
} from "@treeseed/sdk/workflow-support";
|
|
12
|
+
import { fail, guidedResult } from "./utils.js";
|
|
13
|
+
import { promptForNewPassphrase } from "./secret-prompts.js";
|
|
14
|
+
function renderStatus(command, tenantRoot) {
|
|
15
|
+
const status = inspectTreeseedKeyAgentStatus(tenantRoot);
|
|
16
|
+
return guidedResult({
|
|
17
|
+
command,
|
|
18
|
+
summary: status.unlocked ? "Treeseed secrets are unlocked." : "Treeseed secrets are locked.",
|
|
19
|
+
facts: [
|
|
20
|
+
{ label: "Key agent", value: status.running ? "running" : "stopped" },
|
|
21
|
+
{ label: "Wrapped key", value: status.wrappedKeyPresent ? "present" : "missing" },
|
|
22
|
+
{ label: "Migration required", value: status.migrationRequired ? "yes" : "no" },
|
|
23
|
+
{ label: "Idle timeout", value: `${Math.round(status.idleTimeoutMs / 1e3)}s` },
|
|
24
|
+
{ label: "Idle remaining", value: `${Math.round(status.idleRemainingMs / 1e3)}s` },
|
|
25
|
+
{ label: "Passphrase env", value: process.env[TREESEED_MACHINE_KEY_PASSPHRASE_ENV]?.trim() ? "configured" : "unset" },
|
|
26
|
+
{ label: "Key path", value: status.keyPath }
|
|
27
|
+
],
|
|
28
|
+
report: {
|
|
29
|
+
status
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function keyErrorResult(command, error) {
|
|
34
|
+
if (error instanceof TreeseedKeyAgentError) {
|
|
35
|
+
return guidedResult({
|
|
36
|
+
command,
|
|
37
|
+
summary: error.message,
|
|
38
|
+
exitCode: 1,
|
|
39
|
+
report: {
|
|
40
|
+
code: error.code,
|
|
41
|
+
details: error.details ?? null
|
|
42
|
+
},
|
|
43
|
+
nextSteps: error.code === "interactive_required" ? ["Run this command in a TTY or set TREESEED_KEY_PASSPHRASE for the startup unlock path."] : void 0
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
47
|
+
}
|
|
48
|
+
const handleSecretsStatus = async (_invocation, context) => renderStatus("secrets:status", context.cwd);
|
|
49
|
+
const handleSecretsUnlock = async (invocation, context) => {
|
|
50
|
+
try {
|
|
51
|
+
const fromEnv = invocation.args.fromEnv === true;
|
|
52
|
+
const status = fromEnv ? unlockTreeseedSecretSessionFromEnv(context.cwd, {
|
|
53
|
+
allowMigration: invocation.args.allowMigration !== false,
|
|
54
|
+
createIfMissing: invocation.args.createIfMissing !== false
|
|
55
|
+
}) : (() => {
|
|
56
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
57
|
+
throw new TreeseedKeyAgentError(
|
|
58
|
+
"interactive_required",
|
|
59
|
+
"Treeseed secrets:unlock requires a TTY unless you use --from-env."
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return unlockTreeseedSecretSessionInteractive(context.cwd);
|
|
63
|
+
})();
|
|
64
|
+
return guidedResult({
|
|
65
|
+
command: "secrets:unlock",
|
|
66
|
+
summary: "Treeseed secrets unlocked.",
|
|
67
|
+
facts: [
|
|
68
|
+
{ label: "Key agent", value: status.running ? "running" : "stopped" },
|
|
69
|
+
{ label: "Idle remaining", value: `${Math.round(status.idleRemainingMs / 1e3)}s` },
|
|
70
|
+
{ label: "Wrapped key", value: status.wrappedKeyPresent ? "present" : "missing" }
|
|
71
|
+
],
|
|
72
|
+
report: { status }
|
|
73
|
+
});
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return keyErrorResult("secrets:unlock", error);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const handleSecretsLock = async (_invocation, context) => {
|
|
79
|
+
try {
|
|
80
|
+
const status = lockTreeseedSecretSession(context.cwd);
|
|
81
|
+
return guidedResult({
|
|
82
|
+
command: "secrets:lock",
|
|
83
|
+
summary: "Treeseed secrets locked.",
|
|
84
|
+
facts: [
|
|
85
|
+
{ label: "Key agent", value: status.running ? "running" : "stopped" },
|
|
86
|
+
{ label: "Wrapped key", value: status.wrappedKeyPresent ? "present" : "missing" }
|
|
87
|
+
],
|
|
88
|
+
report: { status }
|
|
89
|
+
});
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return keyErrorResult("secrets:lock", error);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
const handleSecretsMigrateKey = async (_invocation, context) => {
|
|
95
|
+
try {
|
|
96
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
97
|
+
throw new TreeseedKeyAgentError("interactive_required", "Treeseed secrets:migrate-key requires a TTY.");
|
|
98
|
+
}
|
|
99
|
+
const passphrase = await promptForNewPassphrase().catch((error) => {
|
|
100
|
+
throw new TreeseedKeyAgentError("unlock_failed", error instanceof Error ? error.message : String(error));
|
|
101
|
+
});
|
|
102
|
+
const result = migrateTreeseedMachineKeyToWrapped(context.cwd, passphrase);
|
|
103
|
+
return guidedResult({
|
|
104
|
+
command: "secrets:migrate-key",
|
|
105
|
+
summary: result.alreadyWrapped ? "Treeseed machine key is already wrapped." : "Treeseed machine key migrated to the wrapped format.",
|
|
106
|
+
facts: [
|
|
107
|
+
{ label: "Key path", value: result.keyPath },
|
|
108
|
+
{ label: "Migrated", value: result.migrated ? "yes" : "no" }
|
|
109
|
+
],
|
|
110
|
+
report: result
|
|
111
|
+
});
|
|
112
|
+
} catch (error) {
|
|
113
|
+
return keyErrorResult("secrets:migrate-key", error);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const handleSecretsRotatePassphrase = async (_invocation, context) => {
|
|
117
|
+
try {
|
|
118
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
119
|
+
throw new TreeseedKeyAgentError("interactive_required", "Treeseed secrets:rotate-passphrase requires a TTY.");
|
|
120
|
+
}
|
|
121
|
+
const passphrase = await promptForNewPassphrase().catch((error) => {
|
|
122
|
+
throw new TreeseedKeyAgentError("unlock_failed", error instanceof Error ? error.message : String(error));
|
|
123
|
+
});
|
|
124
|
+
const result = rotateTreeseedMachineKeyPassphrase(context.cwd, passphrase);
|
|
125
|
+
return guidedResult({
|
|
126
|
+
command: "secrets:rotate-passphrase",
|
|
127
|
+
summary: "Treeseed machine-key passphrase rotated.",
|
|
128
|
+
facts: [{ label: "Key path", value: result.keyPath }],
|
|
129
|
+
report: result
|
|
130
|
+
});
|
|
131
|
+
} catch (error) {
|
|
132
|
+
return keyErrorResult("secrets:rotate-passphrase", error);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
const handleSecretsRotateMachineKey = async (_invocation, context) => {
|
|
136
|
+
try {
|
|
137
|
+
if (!process.env[TREESEED_MACHINE_KEY_PASSPHRASE_ENV]?.trim()) {
|
|
138
|
+
throw new TreeseedKeyAgentError(
|
|
139
|
+
"interactive_required",
|
|
140
|
+
`Set ${TREESEED_MACHINE_KEY_PASSPHRASE_ENV} before rotating the machine key.`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const result = rotateTreeseedMachineKey(context.cwd);
|
|
144
|
+
return guidedResult({
|
|
145
|
+
command: "secrets:rotate-machine-key",
|
|
146
|
+
summary: "Treeseed machine key rotated and re-encrypted.",
|
|
147
|
+
facts: [{ label: "Key path", value: result.keyPath }],
|
|
148
|
+
report: result
|
|
149
|
+
});
|
|
150
|
+
} catch (error) {
|
|
151
|
+
return keyErrorResult("secrets:rotate-machine-key", error);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
export {
|
|
155
|
+
handleSecretsLock,
|
|
156
|
+
handleSecretsMigrateKey,
|
|
157
|
+
handleSecretsRotateMachineKey,
|
|
158
|
+
handleSecretsRotatePassphrase,
|
|
159
|
+
handleSecretsStatus,
|
|
160
|
+
handleSecretsUnlock
|
|
161
|
+
};
|
|
@@ -19,17 +19,43 @@ const handleStatus = async (_invocation, context) => {
|
|
|
19
19
|
{ label: "Package branch aligned", value: state.packageSync.aligned ? "yes" : "no" },
|
|
20
20
|
{ label: "Dirty package repos", value: state.packageSync.dirty ? "yes" : "no" },
|
|
21
21
|
{ label: "Package blockers", value: state.packageSync.blockers.length > 0 ? state.packageSync.blockers.join(" | ") : "(none)" },
|
|
22
|
+
{ label: "Local state", value: state.persistentEnvironments.local.phase },
|
|
23
|
+
{ label: "Staging state", value: state.persistentEnvironments.staging.phase },
|
|
24
|
+
{ label: "Prod state", value: state.persistentEnvironments.prod.phase },
|
|
22
25
|
{ label: "Local initialized", value: state.persistentEnvironments.local.initialized ? "yes" : "no" },
|
|
23
26
|
{ label: "Staging initialized", value: state.persistentEnvironments.staging.initialized ? "yes" : "no" },
|
|
24
27
|
{ label: "Prod initialized", value: state.persistentEnvironments.prod.initialized ? "yes" : "no" },
|
|
28
|
+
{ label: "Staging blockers", value: state.persistentEnvironments.staging.blockers.length > 0 ? state.persistentEnvironments.staging.blockers.join(" | ") : "(none)" },
|
|
29
|
+
{ label: "Prod blockers", value: state.persistentEnvironments.prod.blockers.length > 0 ? state.persistentEnvironments.prod.blockers.join(" | ") : "(none)" },
|
|
25
30
|
{ label: "Preview enabled", value: state.preview.enabled ? "yes" : "no" },
|
|
26
31
|
{ label: "Preview URL", value: state.preview.url ?? "(none)" },
|
|
27
|
-
{ label: "
|
|
28
|
-
{ label: "
|
|
29
|
-
{ label: "
|
|
32
|
+
{ label: "GitHub token/config", value: state.auth.gh ? "configured" : "missing" },
|
|
33
|
+
{ label: "Cloudflare token/config", value: state.auth.wrangler ? "configured" : "missing" },
|
|
34
|
+
{ label: "Railway token/config", value: state.auth.railway ? "configured" : "missing" },
|
|
30
35
|
{ label: "Remote API auth", value: state.auth.remoteApi ? "ready" : "not ready" },
|
|
31
|
-
{ label: "
|
|
32
|
-
{ label: "
|
|
36
|
+
{ label: "Wrapped machine key", value: state.secrets.wrappedKeyPresent ? "present" : "missing" },
|
|
37
|
+
{ label: "Key migration", value: state.secrets.migrationRequired ? "required" : "not needed" },
|
|
38
|
+
{ label: "Key agent", value: state.secrets.keyAgentRunning ? state.secrets.keyAgentUnlocked ? "running/unlocked" : "running/locked" : "stopped" },
|
|
39
|
+
{ label: "Startup passphrase env", value: state.secrets.startupPassphraseConfigured ? "configured" : "unset" },
|
|
40
|
+
{ label: "Market project", value: state.marketConnection.projectSlug ?? state.marketConnection.projectId ?? "(not paired)" },
|
|
41
|
+
{ label: "Market team", value: state.marketConnection.teamSlug ?? state.marketConnection.teamId ?? "(not paired)" },
|
|
42
|
+
{ label: "Market mode", value: state.marketConnection.connectionMode ?? "(not paired)" },
|
|
43
|
+
{ label: "Hub mode", value: state.marketConnection.hubMode ?? "(unknown)" },
|
|
44
|
+
{ label: "Runtime mode", value: state.marketConnection.runtimeMode ?? "(unknown)" },
|
|
45
|
+
{ label: "Runtime registration", value: state.marketConnection.runtimeRegistration ?? "(none)" },
|
|
46
|
+
{ label: "Runtime ready", value: state.marketConnection.runtimeReady ? "yes" : "no" },
|
|
47
|
+
{ label: "Web cache host", value: state.webCache.webHost ?? "(none)" },
|
|
48
|
+
{ label: "Content cache host", value: state.webCache.contentHost ?? "(none)" },
|
|
49
|
+
{ label: "Source page cache", value: state.webCache.sourcePagePolicy ?? "(none)" },
|
|
50
|
+
{ label: "Content page cache", value: state.webCache.contentPagePolicy ?? "(none)" },
|
|
51
|
+
{ label: "R2 object cache", value: state.webCache.r2ObjectPolicy ?? "(none)" },
|
|
52
|
+
{ label: "Cloudflare cache rules", value: state.webCache.cloudflareRulesManaged ? "managed" : "not managed" },
|
|
53
|
+
{ label: "Last deploy purge", value: state.webCache.lastDeployPurgeAt ? `${state.webCache.lastDeployPurgeAt} (${state.webCache.lastDeployPurgeCount ?? 0} urls)` : "(none)" },
|
|
54
|
+
{ label: "Last content purge", value: state.webCache.lastContentPurgeAt ? `${state.webCache.lastContentPurgeAt} (${state.webCache.lastContentPurgeCount ?? 0} urls)` : "(none)" },
|
|
55
|
+
{ label: "Current workstream", value: state.marketConnection.currentWorkstreamId ?? "(none)" },
|
|
56
|
+
{ label: "Approval blockers", value: state.marketConnection.approvalBlockers.length > 0 ? state.marketConnection.approvalBlockers.join(" | ") : "(none)" },
|
|
57
|
+
{ label: "API service", value: state.managedServices.api.enabled ? `${state.managedServices.api.initialized ? "deployed" : "not deployed"}${state.managedServices.api.lastDeployedUrl ? ` (${state.managedServices.api.lastDeployedUrl})` : ""}` : "disabled" },
|
|
58
|
+
{ label: "Worker service", value: state.managedServices.worker.enabled ? `${state.managedServices.worker.initialized ? "deployed" : "not deployed"}${state.managedServices.worker.lastDeployedUrl ? ` (${state.managedServices.worker.lastDeployedUrl})` : ""}` : "disabled" }
|
|
33
59
|
],
|
|
34
60
|
nextSteps: renderWorkflowNextSteps(result),
|
|
35
61
|
report: {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { TreeseedWorkflowError, TreeseedWorkflowSdk } from "@treeseed/sdk/workflow";
|
|
2
|
+
import { TreeseedKeyAgentError } from "@treeseed/sdk/workflow-support";
|
|
2
3
|
function createWorkflowSdk(context, overrides = {}) {
|
|
3
4
|
return new TreeseedWorkflowSdk({
|
|
4
5
|
cwd: context.cwd,
|
|
@@ -40,6 +41,36 @@ function workflowErrorResult(error) {
|
|
|
40
41
|
}
|
|
41
42
|
};
|
|
42
43
|
}
|
|
44
|
+
if (error instanceof TreeseedKeyAgentError) {
|
|
45
|
+
return {
|
|
46
|
+
exitCode: 1,
|
|
47
|
+
stderr: [error.message],
|
|
48
|
+
report: {
|
|
49
|
+
schemaVersion: 1,
|
|
50
|
+
kind: "treeseed.workflow.result",
|
|
51
|
+
command: "status",
|
|
52
|
+
executionMode: "execute",
|
|
53
|
+
runId: null,
|
|
54
|
+
ok: false,
|
|
55
|
+
operation: "status",
|
|
56
|
+
summary: error.message,
|
|
57
|
+
facts: [],
|
|
58
|
+
error: error.message,
|
|
59
|
+
code: error.code,
|
|
60
|
+
payload: null,
|
|
61
|
+
result: null,
|
|
62
|
+
nextSteps: [],
|
|
63
|
+
recovery: null,
|
|
64
|
+
errors: [
|
|
65
|
+
{
|
|
66
|
+
code: error.code,
|
|
67
|
+
message: error.message,
|
|
68
|
+
details: error.details ?? null
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
43
74
|
const message = error instanceof Error ? error.message : String(error);
|
|
44
75
|
return {
|
|
45
76
|
exitCode: 1,
|
package/dist/cli/help-ui.js
CHANGED
|
@@ -482,7 +482,7 @@ function canRenderInkHelp(context) {
|
|
|
482
482
|
);
|
|
483
483
|
}
|
|
484
484
|
function isNonHumanInteractiveEnvironment() {
|
|
485
|
-
return process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true" || process.env.ACT === "true" || process.env.TREESEED_VERIFY_DRIVER === "
|
|
485
|
+
return process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true" || process.env.ACT === "true" || typeof process.env.TREESEED_VERIFY_DRIVER === "string";
|
|
486
486
|
}
|
|
487
487
|
export {
|
|
488
488
|
renderTreeseedHelpInk,
|