@treeseed/sdk 0.4.7 → 0.4.9

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 (43) hide show
  1. package/README.md +1 -1
  2. package/dist/dispatch.d.ts +4 -0
  3. package/dist/dispatch.js +180 -0
  4. package/dist/index.d.ts +4 -2
  5. package/dist/index.js +25 -3
  6. package/dist/operations/providers/default.js +1 -0
  7. package/dist/operations/services/config-runtime.d.ts +121 -26
  8. package/dist/operations/services/config-runtime.js +332 -198
  9. package/dist/operations/services/deploy.d.ts +0 -3
  10. package/dist/operations/services/deploy.js +1 -1
  11. package/dist/operations/services/export-runtime.d.ts +18 -0
  12. package/dist/operations/services/export-runtime.js +136 -0
  13. package/dist/operations/services/railway-deploy.js +2 -2
  14. package/dist/operations/services/runtime-tools.d.ts +0 -1
  15. package/dist/operations/services/runtime-tools.js +1 -2
  16. package/dist/operations-registry.js +1 -0
  17. package/dist/operations-types.d.ts +1 -1
  18. package/dist/platform/book-export.d.ts +78 -0
  19. package/dist/platform/book-export.js +449 -0
  20. package/dist/platform/contracts.d.ts +6 -2
  21. package/dist/platform/deploy-config.d.ts +2 -0
  22. package/dist/platform/deploy-config.js +30 -2
  23. package/dist/platform/env.yaml +5 -0
  24. package/dist/platform/environment.d.ts +10 -1
  25. package/dist/platform/environment.js +82 -6
  26. package/dist/remote.d.ts +65 -9
  27. package/dist/remote.js +104 -28
  28. package/dist/scripts/aggregate-book.js +13 -118
  29. package/dist/scripts/config-treeseed.js +18 -27
  30. package/dist/sdk-dispatch.d.ts +12 -0
  31. package/dist/sdk-dispatch.js +142 -0
  32. package/dist/sdk-types.d.ts +137 -4
  33. package/dist/sdk-types.js +16 -0
  34. package/dist/sdk.d.ts +7 -1
  35. package/dist/sdk.js +69 -0
  36. package/dist/workflow/operations.d.ts +59 -15
  37. package/dist/workflow/operations.js +61 -81
  38. package/dist/workflow-state.js +2 -1
  39. package/dist/workflow-support.d.ts +2 -1
  40. package/dist/workflow-support.js +14 -6
  41. package/dist/workflow.d.ts +11 -1
  42. package/dist/workflow.js +6 -0
  43. package/package.json +6 -1
@@ -1,11 +1,12 @@
1
1
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import { dirname, resolve } from "node:path";
5
5
  import { spawnSync } from "node:child_process";
6
6
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
7
7
  import {
8
8
  getTreeseedEnvironmentSuggestedValues,
9
+ isTreeseedEnvironmentEntryRequired,
9
10
  resolveTreeseedEnvironmentRegistry,
10
11
  TREESEED_ENVIRONMENT_SCOPES,
11
12
  validateTreeseedEnvironmentValues
@@ -14,6 +15,7 @@ import { loadTreeseedManifest } from "../../platform/tenant-config.js";
14
15
  import {
15
16
  createPersistentDeployTarget,
16
17
  ensureGeneratedWranglerConfig,
18
+ loadDeployState,
17
19
  markManagedServicesInitialized,
18
20
  markDeploymentInitialized,
19
21
  provisionCloudflareResources,
@@ -213,6 +215,10 @@ function createDefaultTreeseedMachineConfig({ tenantRoot, deployConfig, tenantCo
213
215
  remote: createDefaultRemoteSettings(),
214
216
  services: createDefaultServiceSettings()
215
217
  },
218
+ shared: {
219
+ values: {},
220
+ secrets: {}
221
+ },
216
222
  environments: Object.fromEntries(
217
223
  TREESEED_ENVIRONMENT_SCOPES.map((scope) => [
218
224
  scope,
@@ -284,7 +290,12 @@ function decryptValue(payload, key) {
284
290
  return decrypted.toString("utf8");
285
291
  }
286
292
  function decryptMachineConfigSecrets(config, key) {
287
- const secrets = {};
293
+ const secrets = {
294
+ shared: {}
295
+ };
296
+ for (const [entryId, payload] of Object.entries(config.shared?.secrets ?? {})) {
297
+ secrets.shared[entryId] = decryptValue(payload, key);
298
+ }
288
299
  for (const scope of TREESEED_ENVIRONMENT_SCOPES) {
289
300
  secrets[scope] = {};
290
301
  for (const [entryId, payload] of Object.entries(config.environments?.[scope]?.secrets ?? {})) {
@@ -294,6 +305,9 @@ function decryptMachineConfigSecrets(config, key) {
294
305
  return secrets;
295
306
  }
296
307
  function applyMachineConfigSecrets(config, secrets, key) {
308
+ for (const [entryId, value] of Object.entries(secrets.shared ?? {})) {
309
+ config.shared.secrets[entryId] = encryptValue(value, key);
310
+ }
297
311
  for (const scope of TREESEED_ENVIRONMENT_SCOPES) {
298
312
  const scoped = config.environments?.[scope];
299
313
  if (!scoped) {
@@ -507,6 +521,16 @@ function loadTreeseedMachineConfig(tenantRoot) {
507
521
  ...parsed.settings?.services ?? {}
508
522
  })
509
523
  },
524
+ shared: {
525
+ values: {
526
+ ...defaults.shared?.values ?? {},
527
+ ...parsed.shared?.values ?? {}
528
+ },
529
+ secrets: {
530
+ ...defaults.shared?.secrets ?? {},
531
+ ...parsed.shared?.secrets ?? {}
532
+ }
533
+ },
510
534
  environments: Object.fromEntries(
511
535
  TREESEED_ENVIRONMENT_SCOPES.map((scope) => [
512
536
  scope,
@@ -613,34 +637,133 @@ function ensureTreeseedGitignoreEntries(tenantRoot) {
613
637
  }
614
638
  return gitignorePath;
615
639
  }
640
+ function dedupeRepairActions(actions) {
641
+ const seen = /* @__PURE__ */ new Set();
642
+ return actions.filter((action) => {
643
+ if (seen.has(action.id)) {
644
+ return false;
645
+ }
646
+ seen.add(action.id);
647
+ return true;
648
+ });
649
+ }
650
+ function applyTreeseedSafeRepairs(tenantRoot) {
651
+ const actions = [];
652
+ ensureTreeseedGitignoreEntries(tenantRoot);
653
+ actions.push({ id: "gitignore", detail: "Ensured Treeseed gitignore entries are present." });
654
+ const envLocalPath = resolve(tenantRoot, ".env.local");
655
+ const envLocalExamplePath = resolve(tenantRoot, ".env.local.example");
656
+ if (!existsSync(envLocalPath) && existsSync(envLocalExamplePath)) {
657
+ copyFileSync(envLocalExamplePath, envLocalPath);
658
+ actions.push({ id: "env-local", detail: "Created .env.local from .env.local.example." });
659
+ }
660
+ const deployConfig = loadTenantDeployConfig(tenantRoot);
661
+ const { configPath } = getTreeseedMachineConfigPaths(tenantRoot);
662
+ if (!existsSync(configPath)) {
663
+ const machineConfig2 = createDefaultTreeseedMachineConfig({
664
+ tenantRoot,
665
+ deployConfig,
666
+ tenantConfig: void 0
667
+ });
668
+ writeTreeseedMachineConfig(tenantRoot, machineConfig2);
669
+ actions.push({ id: "machine-config", detail: "Created the default Treeseed machine config." });
670
+ }
671
+ resolveTreeseedMachineEnvironmentValues(tenantRoot, "local");
672
+ actions.push({ id: "machine-key", detail: "Ensured the Treeseed machine key exists." });
673
+ const machineConfig = loadTreeseedMachineConfig(tenantRoot);
674
+ writeTreeseedMachineConfig(tenantRoot, machineConfig);
675
+ writeTreeseedLocalEnvironmentFiles(tenantRoot);
676
+ actions.push({ id: "local-env", detail: "Regenerated .env.local and .dev.vars from the current machine config." });
677
+ for (const scope of TREESEED_ENVIRONMENT_SCOPES) {
678
+ const target = createPersistentDeployTarget(scope);
679
+ const state = loadDeployState(tenantRoot, deployConfig, { target });
680
+ if (state.readiness?.initialized || scope === "local") {
681
+ ensureGeneratedWranglerConfig(tenantRoot, { target });
682
+ actions.push({ id: `wrangler-${scope}`, detail: `Regenerated the ${scope} generated Wrangler config.` });
683
+ }
684
+ }
685
+ return dedupeRepairActions(actions);
686
+ }
687
+ function decryptMachineEnvironmentBucket(tenantRoot, config, key, bucket) {
688
+ const values = {
689
+ ...bucket?.values ?? {}
690
+ };
691
+ for (const [entryId, payload] of Object.entries(bucket?.secrets ?? {})) {
692
+ values[entryId] = decryptValueWithMachineKey(tenantRoot, payload, key);
693
+ }
694
+ return values;
695
+ }
696
+ function resolveEntryValueFromBuckets(entry, entryId, scope, bucketValuesByScope) {
697
+ if (!entry) {
698
+ return bucketValuesByScope[scope]?.[entryId] ?? bucketValuesByScope.shared?.[entryId] ?? "";
699
+ }
700
+ if (entry?.storage === "shared") {
701
+ const sharedValue = bucketValuesByScope.shared?.[entryId];
702
+ if (typeof sharedValue === "string" && sharedValue.length > 0) {
703
+ return sharedValue;
704
+ }
705
+ const searchScopes = [scope, ...TREESEED_ENVIRONMENT_SCOPES.filter((candidate) => candidate !== scope)];
706
+ for (const candidateScope of searchScopes) {
707
+ const candidateValue = bucketValuesByScope[candidateScope]?.[entryId];
708
+ if (typeof candidateValue === "string" && candidateValue.length > 0) {
709
+ return candidateValue;
710
+ }
711
+ }
712
+ return "";
713
+ }
714
+ return bucketValuesByScope[scope]?.[entryId] ?? "";
715
+ }
616
716
  function resolveTreeseedMachineEnvironmentValues(tenantRoot, scope) {
617
717
  const key = loadMachineKey(tenantRoot);
618
718
  const config = loadTreeseedMachineConfig(tenantRoot);
619
- const values = {
620
- ...config.environments?.[scope]?.values ?? {}
719
+ const registry = collectTreeseedEnvironmentContext(tenantRoot);
720
+ const bucketValuesByScope = {
721
+ shared: decryptMachineEnvironmentBucket(tenantRoot, config, key, config.shared),
722
+ ...Object.fromEntries(
723
+ TREESEED_ENVIRONMENT_SCOPES.map((candidateScope) => [
724
+ candidateScope,
725
+ decryptMachineEnvironmentBucket(tenantRoot, config, key, config.environments?.[candidateScope])
726
+ ])
727
+ )
621
728
  };
622
- for (const [entryId, payload] of Object.entries(config.environments?.[scope]?.secrets ?? {})) {
623
- values[entryId] = decryptValueWithMachineKey(tenantRoot, payload, key);
729
+ const entryById = new Map(registry.entries.map((entry) => [entry.id, entry]));
730
+ const values = {};
731
+ const knownKeys = /* @__PURE__ */ new Set([
732
+ ...Object.keys(bucketValuesByScope.shared ?? {}),
733
+ ...Object.keys(bucketValuesByScope[scope] ?? {}),
734
+ ...registry.entries.map((entry) => entry.id)
735
+ ]);
736
+ for (const entryId of knownKeys) {
737
+ const resolved = resolveEntryValueFromBuckets(entryById.get(entryId), entryId, scope, bucketValuesByScope);
738
+ if (typeof resolved === "string" && resolved.length > 0) {
739
+ values[entryId] = resolved;
740
+ }
624
741
  }
625
742
  return values;
626
743
  }
627
744
  function setTreeseedMachineEnvironmentValue(tenantRoot, scope, entry, value) {
628
745
  const key = loadMachineKey(tenantRoot);
629
746
  const config = loadTreeseedMachineConfig(tenantRoot);
630
- const scoped = config.environments[scope];
747
+ const target = entry.storage === "shared" ? config.shared : config.environments[scope];
748
+ if (entry.storage === "shared") {
749
+ for (const candidateScope of TREESEED_ENVIRONMENT_SCOPES) {
750
+ delete config.environments[candidateScope].values[entry.id];
751
+ delete config.environments[candidateScope].secrets[entry.id];
752
+ }
753
+ }
631
754
  if (entry.sensitivity === "secret") {
632
- delete scoped.values[entry.id];
755
+ delete target.values[entry.id];
633
756
  if (value) {
634
- scoped.secrets[entry.id] = encryptValue(value, key);
757
+ target.secrets[entry.id] = encryptValue(value, key);
635
758
  } else {
636
- delete scoped.secrets[entry.id];
759
+ delete target.secrets[entry.id];
637
760
  }
638
761
  } else {
639
- delete scoped.secrets[entry.id];
762
+ delete target.secrets[entry.id];
640
763
  if (value) {
641
- scoped.values[entry.id] = value;
764
+ target.values[entry.id] = value;
642
765
  } else {
643
- delete scoped.values[entry.id];
766
+ delete target.values[entry.id];
644
767
  }
645
768
  }
646
769
  writeTreeseedMachineConfig(tenantRoot, config);
@@ -687,7 +810,7 @@ function formatTreeseedConfigEnvironmentReport({ tenantRoot, scope, env = proces
687
810
  formatConfigSectionTitle(`Resolved environment values for ${scope}`),
688
811
  revealSecrets ? "Secrets are shown because --show-secrets was provided." : "Secret values are masked. Re-run with --show-secrets to print full values."
689
812
  ];
690
- for (const entry of registry.entries.filter((candidate) => candidate.scopes.includes(scope))) {
813
+ for (const entry of listRelevantTreeseedConfigEntries(registry, scope)) {
691
814
  const value = values[entry.id];
692
815
  const displayValue = typeof value === "string" && value.length > 0 ? entry.sensitivity === "secret" && !revealSecrets ? maskValue(value) : value : "(unset)";
693
816
  lines.push(`${entry.id}=${displayValue} (${sources[entry.id] ?? "unset"})`);
@@ -750,10 +873,11 @@ function writeTreeseedLocalEnvironmentFiles(tenantRoot) {
750
873
  const registry = collectTreeseedEnvironmentContext(tenantRoot);
751
874
  const scope = "local";
752
875
  const values = resolveTreeseedMachineEnvironmentValues(tenantRoot, scope);
753
- const envEntries = registry.entries.filter(
876
+ const orderedEntries = listRelevantTreeseedConfigEntries(registry, scope);
877
+ const envEntries = orderedEntries.filter(
754
878
  (entry) => entry.scopes.includes(scope) && entry.targets.includes("local-file")
755
879
  );
756
- const devVarsEntries = registry.entries.filter(
880
+ const devVarsEntries = orderedEntries.filter(
757
881
  (entry) => entry.scopes.includes(scope) && entry.targets.includes("wrangler-dev-vars")
758
882
  );
759
883
  writeFileSync(resolve(tenantRoot, ".env.local"), `${renderEnvEntries(envEntries, values)}
@@ -1057,14 +1181,14 @@ function syncTreeseedRailwayEnvironment({ tenantRoot, scope = "prod", dryRun = f
1057
1181
  const values = resolveTreeseedMachineEnvironmentValues(tenantRoot, scope);
1058
1182
  const registry = collectTreeseedEnvironmentContext(tenantRoot);
1059
1183
  const railwaySecretNames = registry.entries.filter((entry) => entry.scopes.includes(scope) && entry.targets.includes("railway-secret")).map((entry) => entry.id).filter((key) => typeof values[key] === "string" && values[key].length > 0);
1060
- const services = ["api", "agents", "manager", "worker", "workdayStart", "workdayReport"].map((serviceKey) => {
1184
+ const services = ["api", "agents", "manager", "worker", "runner", "workdayStart", "workdayReport"].map((serviceKey) => {
1061
1185
  const service = deployConfig.services?.[serviceKey];
1062
1186
  if (!service || service.enabled === false || (service.provider ?? "railway") !== "railway") {
1063
1187
  return null;
1064
1188
  }
1065
1189
  const environment = service.environments?.[scope];
1066
1190
  const fallbackServiceName = serviceKey === "api" ? config.settings.services.railway.apiServiceName : serviceKey === "agents" ? config.settings.services.railway.agentsServiceName : "";
1067
- const defaultRootDir = ["api", "manager", "worker", "workdayStart", "workdayReport"].includes(serviceKey) ? "." : "packages/core";
1191
+ const defaultRootDir = ["api", "manager", "worker", "runner", "workdayStart", "workdayReport"].includes(serviceKey) ? "." : "packages/core";
1068
1192
  return {
1069
1193
  service: serviceKey,
1070
1194
  projectName: service.railway?.projectName ?? config.settings.services.railway.projectName,
@@ -1113,20 +1237,6 @@ function formatConfigSectionTitle(label) {
1113
1237
  return colorize(`
1114
1238
  == ${label}`, "1;36");
1115
1239
  }
1116
- function formatConfigFieldPrompt(entry, currentValue) {
1117
- const current = entry.sensitivity === "secret" ? maskValue(currentValue) : currentValue ?? "(unset)";
1118
- return [
1119
- colorize(`
1120
- -- ${entry.label}`, "1;37"),
1121
- colorize(` ${entry.id}`, "36"),
1122
- ` ${entry.description}`,
1123
- ` How to get it: ${entry.howToGet}`,
1124
- ` Used for: ${entry.purposes.join(", ")}`,
1125
- ` Targets: ${entry.targets.join(", ")}`,
1126
- ` Current: ${current}`,
1127
- colorize(' Enter value, press Enter to keep current/default, or "-" to clear', "90")
1128
- ].join("\n");
1129
- }
1130
1240
  function hasConfigValue(values, key) {
1131
1241
  return typeof values[key] === "string" && values[key].trim().length > 0;
1132
1242
  }
@@ -1147,165 +1257,165 @@ function createConfigAuthStatus(values) {
1147
1257
  }
1148
1258
  };
1149
1259
  }
1150
- async function renderInkConfigFrame({ scope, tenantName, tenantSlug, group, entry, currentValue, authStatus }) {
1151
- if (!process.stdout.isTTY) {
1152
- return false;
1153
- }
1154
- try {
1155
- const [{ render, Box, Text }, React] = await Promise.all([
1156
- import("ink"),
1157
- import("react")
1158
- ]);
1159
- const h = React.createElement;
1160
- const status = (ready) => ready ? "ready" : "missing";
1161
- const current = entry.sensitivity === "secret" ? maskValue(currentValue) : currentValue ?? "(unset)";
1162
- const permissionDetails = {
1163
- GH_TOKEN: {
1164
- title: "Required GitHub permissions",
1165
- items: [
1166
- "Scoped to TreeSeed repository",
1167
- "Contents: read/write",
1168
- "Environments: read/write",
1169
- "Secrets and variables: read/write",
1170
- "Actions and workflows: read/write",
1171
- "Pull requests: read/write",
1172
- "Issues: read/write"
1173
- ]
1260
+ const CONFIG_GROUP_ORDER = ["auth", "cloudflare", "local-development", "forms", "smtp"];
1261
+ function configGroupRank(group) {
1262
+ const index = CONFIG_GROUP_ORDER.indexOf(group);
1263
+ return index === -1 ? CONFIG_GROUP_ORDER.length : index;
1264
+ }
1265
+ function listRelevantTreeseedConfigEntries(registry, scope) {
1266
+ return registry.entries.filter(
1267
+ (entry) => entry.scopes.includes(scope) && (!entry.isRelevant || entry.isRelevant(registry.context, scope, "config"))
1268
+ ).sort((left, right) => {
1269
+ const leftRequired = isTreeseedEnvironmentEntryRequired(left, registry.context, scope, "config");
1270
+ const rightRequired = isTreeseedEnvironmentEntryRequired(right, registry.context, scope, "config");
1271
+ if (leftRequired !== rightRequired) {
1272
+ return leftRequired ? -1 : 1;
1273
+ }
1274
+ if (left.purposes.length !== right.purposes.length) {
1275
+ return right.purposes.length - left.purposes.length;
1276
+ }
1277
+ if (configGroupRank(left.group) !== configGroupRank(right.group)) {
1278
+ return configGroupRank(left.group) - configGroupRank(right.group);
1279
+ }
1280
+ return left.label.localeCompare(right.label);
1281
+ });
1282
+ }
1283
+ function buildConfigEntrySnapshot(scope, entry, currentValue, suggestedValue) {
1284
+ return {
1285
+ id: entry.id,
1286
+ label: entry.label,
1287
+ group: entry.group,
1288
+ description: entry.description,
1289
+ howToGet: entry.howToGet,
1290
+ sensitivity: entry.sensitivity,
1291
+ targets: [...entry.targets],
1292
+ purposes: [...entry.purposes],
1293
+ storage: entry.storage ?? "scoped",
1294
+ scope,
1295
+ sharedScopes: entry.storage === "shared" ? [...entry.scopes] : [scope],
1296
+ required: false,
1297
+ currentValue,
1298
+ suggestedValue,
1299
+ effectiveValue: currentValue || suggestedValue || ""
1300
+ };
1301
+ }
1302
+ function collectTreeseedConfigContext({
1303
+ tenantRoot,
1304
+ scopes = [...TREESEED_ENVIRONMENT_SCOPES],
1305
+ env = process.env
1306
+ }) {
1307
+ ensureTreeseedGitignoreEntries(tenantRoot);
1308
+ const registry = collectTreeseedEnvironmentContext(tenantRoot);
1309
+ const { configPath, keyPath } = getTreeseedMachineConfigPaths(tenantRoot);
1310
+ const valuesByScope = Object.fromEntries(
1311
+ scopes.map((scope) => [scope, collectTreeseedConfigSeedValues(tenantRoot, scope, env)])
1312
+ );
1313
+ const suggestedValuesByScope = Object.fromEntries(
1314
+ scopes.map((scope) => [scope, getTreeseedEnvironmentSuggestedValues({
1315
+ scope,
1316
+ purpose: "config",
1317
+ deployConfig: registry.context.deployConfig,
1318
+ tenantConfig: registry.context.tenantConfig,
1319
+ plugins: registry.context.plugins,
1320
+ values: valuesByScope[scope]
1321
+ })])
1322
+ );
1323
+ const authStatusByScope = Object.fromEntries(
1324
+ scopes.map((scope) => [scope, createConfigAuthStatus(valuesByScope[scope])])
1325
+ );
1326
+ const validationByScope = Object.fromEntries(
1327
+ scopes.map((scope) => [scope, validateTreeseedEnvironmentValues({
1328
+ values: {
1329
+ ...suggestedValuesByScope[scope],
1330
+ ...valuesByScope[scope]
1174
1331
  },
1175
- CLOUDFLARE_API_TOKEN: {
1176
- title: "Required Cloudflare permissions",
1177
- items: [
1178
- "Scoped to domain and account",
1179
- "Account Cloudflare Pages: edit",
1180
- "Account Workers Scripts: edit",
1181
- "Account Workers KV Storage: edit",
1182
- "Account D1: edit",
1183
- "Account Queues: edit",
1184
- "Zone DNS: edit"
1185
- ]
1186
- }
1187
- }[entry.id];
1188
- const frame = h(
1189
- Box,
1190
- { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, marginY: 1 },
1191
- h(Text, { color: "cyan", bold: true }, `Treeseed Config - ${scope}`),
1192
- h(Text, null, `${tenantName} (${tenantSlug})`),
1193
- authStatus ? h(
1194
- Text,
1195
- null,
1196
- `GitHub ${status(authStatus.gh?.authenticated)} - Cloudflare ${status(authStatus.wrangler?.authenticated)} - Railway ${status(authStatus.railway?.authenticated)}`
1197
- ) : null,
1198
- h(Text, { color: "yellow" }, `[${group}] ${entry.label}`),
1199
- h(Text, { color: "gray" }, entry.id),
1200
- h(Text, null, entry.description),
1201
- h(Text, null, `How: ${entry.howToGet}`),
1202
- permissionDetails ? h(
1203
- Box,
1204
- { flexDirection: "column", marginTop: 1 },
1205
- h(Text, { color: "green", bold: true }, permissionDetails.title),
1206
- ...permissionDetails.items.map((permission) => h(Text, { key: permission }, `- ${permission}`))
1207
- ) : null,
1208
- h(Text, null, `Used for: ${entry.purposes.join(", ")}`),
1209
- h(Text, null, `Targets: ${entry.targets.join(", ")}`),
1210
- h(Text, { color: currentValue ? "green" : "red" }, `Current: ${current}`)
1211
- );
1212
- const instance = render(frame, { exitOnCtrlC: false });
1213
- await new Promise((resolvePromise) => setTimeout(resolvePromise, 20));
1214
- instance.unmount();
1215
- return true;
1216
- } catch {
1217
- return false;
1332
+ scope,
1333
+ purpose: "config",
1334
+ deployConfig: registry.context.deployConfig,
1335
+ tenantConfig: registry.context.tenantConfig,
1336
+ plugins: registry.context.plugins
1337
+ })])
1338
+ );
1339
+ const entriesByScope = Object.fromEntries(
1340
+ scopes.map((scope) => [scope, listRelevantTreeseedConfigEntries(registry, scope).map((entry) => ({
1341
+ ...buildConfigEntrySnapshot(
1342
+ scope,
1343
+ entry,
1344
+ valuesByScope[scope][entry.id] ?? "",
1345
+ suggestedValuesByScope[scope][entry.id] ?? ""
1346
+ ),
1347
+ required: isTreeseedEnvironmentEntryRequired(entry, registry.context, scope, "config")
1348
+ }))])
1349
+ );
1350
+ return {
1351
+ tenantRoot,
1352
+ scopes,
1353
+ project: {
1354
+ name: registry.context.deployConfig.name,
1355
+ slug: registry.context.deployConfig.slug,
1356
+ siteUrl: registry.context.deployConfig.siteUrl
1357
+ },
1358
+ configPath,
1359
+ keyPath,
1360
+ entriesByScope,
1361
+ valuesByScope,
1362
+ suggestedValuesByScope,
1363
+ authStatusByScope,
1364
+ validationByScope,
1365
+ registry
1366
+ };
1367
+ }
1368
+ function applyTreeseedConfigValues({
1369
+ tenantRoot,
1370
+ updates,
1371
+ writeLocalFiles = true,
1372
+ applyLocalEnvironment = true
1373
+ }) {
1374
+ const registry = collectTreeseedEnvironmentContext(tenantRoot);
1375
+ const entryById = new Map(registry.entries.map((entry) => [entry.id, entry]));
1376
+ const applied = [];
1377
+ for (const update of updates) {
1378
+ const entry = entryById.get(update.entryId);
1379
+ if (!entry) {
1380
+ throw new Error(`Unknown Treeseed config entry "${update.entryId}".`);
1381
+ }
1382
+ if (!entry.scopes.includes(update.scope)) {
1383
+ throw new Error(`Treeseed config entry "${update.entryId}" does not apply to ${update.scope}.`);
1384
+ }
1385
+ setTreeseedMachineEnvironmentValue(tenantRoot, update.scope, entry, update.value);
1386
+ applied.push({
1387
+ scope: entry.storage === "shared" ? "shared" : update.scope,
1388
+ id: entry.id,
1389
+ reused: update.reused === true,
1390
+ cleared: update.value.length === 0
1391
+ });
1392
+ }
1393
+ const envFiles = writeLocalFiles ? writeTreeseedLocalEnvironmentFiles(tenantRoot) : null;
1394
+ if (applyLocalEnvironment) {
1395
+ applyTreeseedEnvironmentToProcess({ tenantRoot, scope: "local", override: true });
1218
1396
  }
1397
+ return {
1398
+ updated: applied,
1399
+ envFiles
1400
+ };
1219
1401
  }
1220
- async function runTreeseedConfigWizard({
1402
+ function finalizeTreeseedConfig({
1221
1403
  tenantRoot,
1222
- scopes = ["local", "staging", "prod"],
1404
+ scopes = [...TREESEED_ENVIRONMENT_SCOPES],
1223
1405
  sync = "all",
1224
- prompt,
1225
- authStatus,
1226
- write = console.log,
1227
1406
  env = process.env,
1228
- useInk = false,
1229
- printEnv = false,
1230
- revealSecrets = false,
1231
- checkConnections = true
1407
+ checkConnections = true,
1408
+ initializePersistent = true
1232
1409
  }) {
1233
- ensureTreeseedGitignoreEntries(tenantRoot);
1234
1410
  const registry = collectTreeseedEnvironmentContext(tenantRoot);
1235
- const groups = ["auth", "local-development", "forms", "smtp", "cloudflare"];
1236
1411
  const summary = {
1237
1412
  scopes,
1238
- updated: [],
1239
1413
  synced: {},
1240
1414
  initialized: [],
1241
- connectionChecks: []
1415
+ connectionChecks: [],
1416
+ validationByScope: {}
1242
1417
  };
1243
1418
  for (const scope of scopes) {
1244
- const existingValues = collectTreeseedConfigSeedValues(tenantRoot, scope, env);
1245
- const configAuthStatus = createConfigAuthStatus(existingValues);
1246
- const suggested = getTreeseedEnvironmentSuggestedValues({
1247
- scope,
1248
- deployConfig: registry.context.deployConfig,
1249
- tenantConfig: registry.context.tenantConfig,
1250
- plugins: registry.context.plugins
1251
- });
1252
- write(formatConfigSectionTitle(`Treeseed configuration for ${scope}`));
1253
- write(`Tenant: ${registry.context.deployConfig.name} (${registry.context.deployConfig.slug})`);
1254
- if (authStatus) {
1255
- write(`GitHub token: ${configAuthStatus.gh.authenticated ? colorize("ready", "32") : colorize("missing", "31")}`);
1256
- write(`Cloudflare token: ${configAuthStatus.wrangler.authenticated ? colorize("ready", "32") : colorize("missing", "31")}`);
1257
- write(`Railway token: ${configAuthStatus.railway.authenticated ? colorize("ready", "32") : colorize("missing", "31")}`);
1258
- }
1259
- for (const group of groups) {
1260
- const groupEntries = registry.entries.filter(
1261
- (entry) => entry.group === group && entry.scopes.includes(scope) && (!entry.isRelevant || entry.isRelevant(registry.context, scope, "config"))
1262
- );
1263
- if (groupEntries.length === 0) {
1264
- continue;
1265
- }
1266
- write(formatConfigSectionTitle(group));
1267
- for (const entry of groupEntries) {
1268
- const currentValue = existingValues[entry.id];
1269
- const suggestedValue = suggested[entry.id];
1270
- const displayValue = currentValue ?? suggestedValue ?? "";
1271
- const entryAuthStatus = createConfigAuthStatus(existingValues);
1272
- const renderedInk = useInk && await renderInkConfigFrame({
1273
- scope,
1274
- tenantName: registry.context.deployConfig.name,
1275
- tenantSlug: registry.context.deployConfig.slug,
1276
- group,
1277
- entry,
1278
- currentValue,
1279
- authStatus: entryAuthStatus
1280
- });
1281
- if (!renderedInk) {
1282
- write(formatConfigFieldPrompt(entry, currentValue));
1283
- }
1284
- const answer = (await prompt(
1285
- `${entry.id}${displayValue ? ` [${entry.sensitivity === "secret" ? "keep current" : displayValue}]` : ""}: `
1286
- )).trim();
1287
- if (answer === "" && displayValue) {
1288
- setTreeseedMachineEnvironmentValue(tenantRoot, scope, entry, displayValue);
1289
- existingValues[entry.id] = displayValue;
1290
- summary.updated.push({ scope, id: entry.id, reused: true });
1291
- continue;
1292
- }
1293
- if (answer === "" && !displayValue) {
1294
- setTreeseedMachineEnvironmentValue(tenantRoot, scope, entry, "");
1295
- existingValues[entry.id] = "";
1296
- continue;
1297
- }
1298
- if (answer === "-") {
1299
- setTreeseedMachineEnvironmentValue(tenantRoot, scope, entry, "");
1300
- existingValues[entry.id] = "";
1301
- summary.updated.push({ scope, id: entry.id, cleared: true });
1302
- continue;
1303
- }
1304
- setTreeseedMachineEnvironmentValue(tenantRoot, scope, entry, answer);
1305
- existingValues[entry.id] = answer;
1306
- summary.updated.push({ scope, id: entry.id, reused: false });
1307
- }
1308
- }
1309
1419
  const validation = validateTreeseedEnvironmentValues({
1310
1420
  values: resolveTreeseedMachineEnvironmentValues(tenantRoot, scope),
1311
1421
  scope,
@@ -1314,37 +1424,32 @@ async function runTreeseedConfigWizard({
1314
1424
  tenantConfig: registry.context.tenantConfig,
1315
1425
  plugins: registry.context.plugins
1316
1426
  });
1427
+ summary.validationByScope[scope] = validation;
1317
1428
  if (!validation.ok) {
1318
1429
  const details = [...validation.missing, ...validation.invalid].map((problem) => `- ${problem.message}`).join("\n");
1319
1430
  throw new Error(`Treeseed config validation failed for ${scope}:
1320
1431
  ${details}`);
1321
1432
  }
1322
- if (printEnv) {
1323
- write(formatTreeseedConfigEnvironmentReport({ tenantRoot, scope, env, revealSecrets }));
1324
- }
1325
1433
  if (checkConnections) {
1326
- const connectionReport = checkTreeseedProviderConnections({ tenantRoot, scope, env });
1327
- summary.connectionChecks.push(connectionReport);
1328
- writeProviderConnectionReport(write, connectionReport);
1434
+ summary.connectionChecks.push(checkTreeseedProviderConnections({ tenantRoot, scope, env }));
1329
1435
  }
1330
1436
  }
1331
1437
  writeTreeseedLocalEnvironmentFiles(tenantRoot);
1332
1438
  syncManagedServiceSettingsFromDeployConfig(tenantRoot);
1333
- for (const scope of scopes) {
1334
- if (scope === "local") {
1335
- continue;
1336
- }
1337
- applyTreeseedEnvironmentToProcess({ tenantRoot, scope, override: true });
1338
- const initialized = initializeTreeseedPersistentEnvironment({ tenantRoot, scope });
1339
- if (write) {
1340
- writeDeploySummary(write, initialized.summary);
1439
+ if (initializePersistent) {
1440
+ for (const scope of scopes) {
1441
+ if (scope === "local") {
1442
+ continue;
1443
+ }
1444
+ applyTreeseedEnvironmentToProcess({ tenantRoot, scope, override: true });
1445
+ const initialized = initializeTreeseedPersistentEnvironment({ tenantRoot, scope });
1446
+ summary.initialized.push({
1447
+ scope,
1448
+ secrets: initialized.secrets.length,
1449
+ target: initialized.summary.target
1450
+ });
1451
+ markManagedServicesInitialized(tenantRoot, { scope });
1341
1452
  }
1342
- summary.initialized.push({
1343
- scope,
1344
- secrets: initialized.secrets.length,
1345
- target: initialized.summary.target
1346
- });
1347
- markManagedServicesInitialized(tenantRoot, { scope });
1348
1453
  }
1349
1454
  if (sync === "github" || sync === "all") {
1350
1455
  summary.synced.github = syncTreeseedGitHubEnvironment({ tenantRoot, scope: scopes.at(-1) ?? "prod" });
@@ -1357,25 +1462,55 @@ ${details}`);
1357
1462
  }
1358
1463
  return summary;
1359
1464
  }
1465
+ function collectTreeseedPrintEnvReport({
1466
+ tenantRoot,
1467
+ scope,
1468
+ env = process.env,
1469
+ revealSecrets = false
1470
+ }) {
1471
+ const registry = collectTreeseedEnvironmentContext(tenantRoot);
1472
+ const { values, sources } = collectTreeseedConfigSeedValueSources(tenantRoot, scope, env);
1473
+ return {
1474
+ scope,
1475
+ revealSecrets,
1476
+ entries: listRelevantTreeseedConfigEntries(registry, scope).map((entry) => {
1477
+ const rawValue = values[entry.id] ?? "";
1478
+ return {
1479
+ id: entry.id,
1480
+ label: entry.label,
1481
+ sensitivity: entry.sensitivity,
1482
+ value: rawValue,
1483
+ displayValue: rawValue ? entry.sensitivity === "secret" && !revealSecrets ? maskValue(rawValue) : rawValue : "(unset)",
1484
+ source: sources[entry.id] ?? "unset"
1485
+ };
1486
+ })
1487
+ };
1488
+ }
1360
1489
  export {
1361
1490
  DEFAULT_TEMPLATE_CATALOG_URL,
1362
1491
  DEFAULT_TREESEED_API_BASE_URL,
1363
1492
  TREESEED_API_BASE_URL_ENV,
1364
1493
  TREESEED_TEMPLATE_CATALOG_URL_ENV,
1494
+ applyTreeseedConfigValues,
1365
1495
  applyTreeseedEnvironmentToProcess,
1496
+ applyTreeseedSafeRepairs,
1366
1497
  assertTreeseedCommandEnvironment,
1367
1498
  checkTreeseedProviderConnections,
1368
1499
  clearTreeseedRemoteSession,
1500
+ collectTreeseedConfigContext,
1369
1501
  collectTreeseedConfigSeedValues,
1370
1502
  collectTreeseedEnvironmentContext,
1503
+ collectTreeseedPrintEnvReport,
1371
1504
  createDefaultTreeseedMachineConfig,
1372
1505
  ensureTreeseedActVerificationTooling,
1373
1506
  ensureTreeseedGitignoreEntries,
1507
+ finalizeTreeseedConfig,
1374
1508
  formatTreeseedConfigEnvironmentReport,
1375
1509
  formatTreeseedProviderConnectionReport,
1376
1510
  getTreeseedMachineConfigPaths,
1377
1511
  getTreeseedRemoteAuthPaths,
1378
1512
  initializeTreeseedPersistentEnvironment,
1513
+ listRelevantTreeseedConfigEntries,
1379
1514
  loadTreeseedMachineConfig,
1380
1515
  loadTreeseedRemoteAuthState,
1381
1516
  resolveTreeseedMachineEnvironmentValues,
@@ -1384,7 +1519,6 @@ export {
1384
1519
  resolveTreeseedTemplateCatalogCachePath,
1385
1520
  resolveTreeseedTemplateCatalogEndpoint,
1386
1521
  rotateTreeseedMachineKey,
1387
- runTreeseedConfigWizard,
1388
1522
  setTreeseedMachineEnvironmentValue,
1389
1523
  setTreeseedRemoteSession,
1390
1524
  syncTreeseedCloudflareEnvironment,