sandstream-kit 1.4.3 → 1.6.0

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.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync } from "node:fs";
2
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
3
3
  import { writeFile, access, mkdir } from "node:fs/promises";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { resolve, dirname, join } from "node:path";
@@ -56,7 +56,7 @@ import { promptConfirm } from "./utils/prompt.js";
56
56
  import { c } from "./utils/colors.js";
57
57
  import { gatherStatus } from "./status.js";
58
58
  import { KIT_FILE, resolveConfigPath } from "./cli-shared.js";
59
- import { checkContext, applyContext, contextPrompt, gatherLive, suggestContextToml } from "./context-lock.js";
59
+ import { checkContext, applyContext, contextPrompt, gatherLive, suggestContextToml, hasLockableContext } from "./context-lock.js";
60
60
  import { cmdEnv } from "./commands/env.js";
61
61
  import { cmdAuth } from "./commands/auth.js";
62
62
  import { cmdAudit } from "./commands/audit.js";
@@ -65,7 +65,9 @@ import { cmdHooks } from "./commands/hooks.js";
65
65
  import { resolveAllAuth } from "./service-auth.js";
66
66
  import { runDoctor } from "./doctor.js";
67
67
  import { detectStack } from "./stack-detector.js";
68
- import { generateToml } from "./toml-generator.js";
68
+ import { generateToml, parseEnvTemplateKeys } from "./toml-generator.js";
69
+ import { vaultMeta, detectSecretStore } from "./vault-meta.js";
70
+ import { vaultCliInstalled } from "./secret-backends.js";
69
71
  import { createPlugin } from "./create-plugin.js";
70
72
  import { cmdPlugin } from "./plugins-cli.js";
71
73
  import { cloneRepository } from "./clone.js";
@@ -630,6 +632,27 @@ async function cmdSecrets() {
630
632
  else if (skipped === "nothing-resolved") {
631
633
  console.log(`\n ${c.yellow}!${c.reset} Skipped .env.local ${c.dim}— no secrets resolved (vault empty/unauthed); existing file left intact${c.reset}`);
632
634
  }
635
+ // Loud, actionable vault-readiness flag. A chosen vault that resolves zero
636
+ // secrets is almost always "CLI installed but not logged in" — surface
637
+ // that once, with the exact next command, instead of leaving the user to
638
+ // infer it from a column of per-key ✗ lines.
639
+ const meta = vaultMeta(secretsConfig.store);
640
+ const resolvedCount = results.filter((r) => r.resolved).length;
641
+ if (meta && results.length > 0 && resolvedCount === 0) {
642
+ const installed = meta.miseTool ? await vaultCliInstalled(meta.miseTool) : true;
643
+ console.log(`\n ${c.yellow}${c.bold}! Vault "${secretsConfig.store}" is configured but resolved 0 secrets.${c.reset}`);
644
+ if (meta.miseTool && !installed) {
645
+ console.log(` ${c.dim}The ${meta.label} CLI isn't installed yet — run ${c.reset}${c.bold}kit setup${c.reset}${c.dim} (installs it via mise).${c.reset}`);
646
+ }
647
+ else if (meta.loginCmd) {
648
+ console.log(` ${c.dim}The ${meta.label} CLI is installed but not authenticated. Log in:${c.reset}`);
649
+ console.log(` ${c.bold}${meta.loginCmd}${c.reset}`);
650
+ if (meta.initCmd) {
651
+ console.log(` ${c.dim}then bind this repo:${c.reset} ${c.bold}${meta.initCmd}${c.reset}`);
652
+ }
653
+ console.log(` ${c.dim}then re-run ${c.reset}${c.bold}kit secrets${c.reset}${c.dim}.${c.reset}`);
654
+ }
655
+ }
633
656
  console.log();
634
657
  return allOk;
635
658
  });
@@ -706,6 +729,27 @@ async function cmdEscalate() {
706
729
  return false;
707
730
  });
708
731
  }
732
+ /**
733
+ * Run a command string from a `.kit.toml [setup]` field. These are commands the
734
+ * user configured themselves, but kit's exec invariant forbids a shell — so we
735
+ * split on whitespace (fine for `pnpm install`, `supabase db push`, `uv sync`,
736
+ * `go mod download`) and REFUSE anything with shell operators rather than
737
+ * mis-running it. Returns true on exit 0.
738
+ */
739
+ async function runConfiguredCommand(label, cmdStr) {
740
+ if (/[&|;<>`$()]/.test(cmdStr)) {
741
+ console.log(` ${c.yellow}!${c.reset} ${label}: ${c.dim}has shell operators — run it yourself: ${c.reset}${c.bold}${cmdStr}${c.reset}`);
742
+ return false;
743
+ }
744
+ console.log(` ${c.dim}$ ${cmdStr}${c.reset}`);
745
+ const res = await executeCommand({ commandArgs: cmdStr.trim().split(/\s+/), cwd: process.cwd() });
746
+ if (res.exitCode === 0) {
747
+ console.log(` ${c.green}✓${c.reset} ${label}`);
748
+ return true;
749
+ }
750
+ console.log(` ${c.red}✗${c.reset} ${label} ${c.dim}(exit ${res.exitCode})${c.reset}`);
751
+ return false;
752
+ }
709
753
  async function cmdSetup() {
710
754
  console.log(`${c.bold}${c.cyan}kit setup${c.reset}`);
711
755
  console.log(`${c.dim}${"─".repeat(50)}${c.reset}\n`);
@@ -739,6 +783,13 @@ async function cmdSetup() {
739
783
  console.log(`${c.dim}Fix the issues above and run ${c.reset}${c.bold}kit setup${c.reset}${c.dim} again.${c.reset}`);
740
784
  return false;
741
785
  }
786
+ // Project dependencies. cmdInstall above provisions the TOOLCHAIN (node, pnpm,
787
+ // … via mise); now install the project's own deps so the repo actually works
788
+ // after setup. The generated [setup].install was never executed before — kit
789
+ // installed the toolchain but left node_modules absent.
790
+ if (config.setup?.install) {
791
+ await runConfiguredCommand("deps installed", config.setup.install);
792
+ }
742
793
  // Step 2: Git Hooks
743
794
  if (config.hooks && Object.keys(config.hooks).length > 0 && isGitRepository()) {
744
795
  console.log(`${c.bold}[2/6] Git Hooks${c.reset}`);
@@ -753,7 +804,39 @@ async function cmdSetup() {
753
804
  }
754
805
  // Step 4: Secrets
755
806
  console.log(`${c.bold}[4/6] Secrets${c.reset}`);
807
+ // Harden .gitignore BEFORE secrets are materialized. kit's headline is
808
+ // "secret-safe", but cmdSecrets writes .env.local below — if the repo's
809
+ // .gitignore doesn't already cover it, the next `git add .` stages real
810
+ // secrets. Patching is a non-destructive, repo-local append, so we do it by
811
+ // default and announce it (standalone `kit security check-gitignore --fix`
812
+ // remains for the manual path).
813
+ if (isGitRepository()) {
814
+ const gi = await checkGitignore(process.cwd());
815
+ if (gi.missingPatterns.length > 0) {
816
+ const patched = await patchGitignore(process.cwd());
817
+ const names = gi.missingPatterns.slice(0, 3).map((m) => m.pattern).join(", ");
818
+ console.log(` ${c.green}✓${c.reset} hardened .gitignore ${c.dim}(+${patched.added}: ${names}${gi.missingPatterns.length > 3 ? ", …" : ""}) — review + commit it${c.reset}`);
819
+ }
820
+ }
756
821
  const secretsOk = await cmdSecrets();
822
+ // [setup].migrate / .seed are intentionally NOT auto-run: a configured migrate
823
+ // (`supabase db push`, `prisma migrate deploy`) can mutate a linked — possibly
824
+ // production — database. Run only on explicit opt-in; otherwise surface the
825
+ // exact command so applying it stays a deliberate human action.
826
+ if (config.setup?.migrate || config.setup?.seed) {
827
+ if (hasFlag(process.argv, "--with-migrate")) {
828
+ console.log(`${c.bold}[+] Migrate / seed${c.reset}`);
829
+ if (config.setup.migrate)
830
+ await runConfiguredCommand("migrate", config.setup.migrate);
831
+ if (config.setup.seed)
832
+ await runConfiguredCommand("seed", config.setup.seed);
833
+ console.log();
834
+ }
835
+ else {
836
+ const cmds = [config.setup.migrate, config.setup.seed].filter(Boolean).join(" · ");
837
+ console.log(` ${c.yellow}!${c.reset} ${c.dim}Skipping migrate/seed (may mutate a real DB). Run deliberately: ${c.reset}${c.bold}${cmds}${c.reset}${c.dim} or ${c.reset}${c.bold}kit setup --with-migrate${c.reset}`);
838
+ }
839
+ }
757
840
  // Step 5: Agent config — teach the present agent(s) to use kit. Idempotent;
758
841
  // only writes a managed block, so re-running setup leaves it unchanged.
759
842
  console.log(`${c.bold}[5/6] Agent config${c.reset}`);
@@ -794,7 +877,16 @@ async function cmdSetup() {
794
877
  }
795
878
  console.log();
796
879
  }
797
- const allOk = installOk && loginOk && secretsOk && verifyOk;
880
+ // Project verify (e.g. the configured build). Distinct from cmdCheck above,
881
+ // which audits setup STATE — this proves the app actually builds. Run last so
882
+ // deps + secrets are in place.
883
+ let setupVerifyOk = true;
884
+ if (config.setup?.verify) {
885
+ console.log(`\n${c.bold}[+] Verify build${c.reset}`);
886
+ setupVerifyOk = await runConfiguredCommand(config.setup.verify, config.setup.verify);
887
+ console.log();
888
+ }
889
+ const allOk = installOk && loginOk && secretsOk && verifyOk && setupVerifyOk;
798
890
  if (allOk) {
799
891
  console.log(`${c.bold}${c.green}Setup complete — you're ready to go! ✓${c.reset}\n`);
800
892
  }
@@ -943,10 +1035,16 @@ async function generateConfigFile(configPath, nonInteractive) {
943
1035
  `${c.bold}kit secrets migrate${c.reset}${c.yellow} after.${c.reset}\n`);
944
1036
  }
945
1037
  // ── Secret-backend choice (interactive) ────────────────────────────────
946
- let chosenStore = "1password";
1038
+ // Respect a backend the repo is already bound to (.infisical.json / doppler.yaml);
1039
+ // it becomes the default (and the non-interactive choice) instead of 1Password.
1040
+ const detectedStore = await detectSecretStore(async (p) => existsSync(resolve(process.cwd(), p)));
1041
+ let chosenStore = detectedStore ?? "1password";
1042
+ if (detectedStore) {
1043
+ console.log(` ${c.green}✓${c.reset} Detected ${c.bold}${detectedStore}${c.reset} config in repo — using it as the secret backend.\n`);
1044
+ }
947
1045
  if (!nonInteractive) {
948
- chosenStore = (await promptSelect("Secret backend?", [
949
- { value: "1password", label: "1Password", hint: "interactive signin via op CLI", recommended: true },
1046
+ const opts = [
1047
+ { value: "1password", label: "1Password", hint: "interactive signin via op CLI" },
950
1048
  { value: "infisical", label: "Infisical", hint: "self-hosted or cloud, token-based" },
951
1049
  { value: "vault", label: "HashiCorp Vault", hint: "KV v2 paths" },
952
1050
  { value: "aws-sm", label: "AWS Secrets Manager", hint: "IAM credentials required" },
@@ -955,10 +1053,29 @@ async function generateConfigFile(configPath, nonInteractive) {
955
1053
  { value: "doppler", label: "Doppler", hint: "doppler login required" },
956
1054
  { value: "bitwarden", label: "Bitwarden", hint: "bw login + unlock required" },
957
1055
  { value: "env", label: "env (no vault)", hint: "not recommended — use only for local dev" },
958
- ]));
1056
+ ].map((o) => ({ ...o, recommended: o.value === chosenStore }));
1057
+ chosenStore = (await promptSelect("Secret backend?", opts));
959
1058
  console.log();
960
1059
  }
961
- const tomlContent = generateToml(stack, { secretsStore: chosenStore });
1060
+ // Detect a Dockerfile so generateToml can provision the trivy container/IaC
1061
+ // scanner only where it applies.
1062
+ const hasDockerfile = existsSync(resolve(process.cwd(), "Dockerfile")) ||
1063
+ existsSync(resolve(process.cwd(), "docker-compose.yml")) ||
1064
+ existsSync(resolve(process.cwd(), "compose.yml"));
1065
+ // Seed [secrets.keys] from an existing .env example so the project's real
1066
+ // secret contract isn't lost to just the detected services' template keys.
1067
+ let extraSecretKeys = [];
1068
+ for (const f of [".env.example", ".env.template", ".env.sample"]) {
1069
+ const p = resolve(process.cwd(), f);
1070
+ if (existsSync(p)) {
1071
+ extraSecretKeys = parseEnvTemplateKeys(readFileSync(p, "utf-8"));
1072
+ if (extraSecretKeys.length > 0) {
1073
+ console.log(` ${c.green}✓${c.reset} Seeded ${extraSecretKeys.length} key(s) from ${c.bold}${f}${c.reset}\n`);
1074
+ }
1075
+ break;
1076
+ }
1077
+ }
1078
+ const tomlContent = generateToml(stack, { secretsStore: chosenStore, hasDockerfile, extraSecretKeys });
962
1079
  // Show diff preview
963
1080
  console.log(`${c.bold}Preview — .kit.toml${c.reset}\n`);
964
1081
  for (const line of tomlContent.split("\n")) {
@@ -980,8 +1097,57 @@ async function generateConfigFile(configPath, nonInteractive) {
980
1097
  }
981
1098
  await writeFile(configPath, tomlContent, "utf-8");
982
1099
  console.log(` ${c.green}✓${c.reset} Generated ${c.bold}.kit.toml${c.reset}\n`);
1100
+ // Close the loop on the vault choice: tell the user exactly what `kit setup`
1101
+ // will provision and what they still have to do themselves (login is their
1102
+ // account action — kit guides it, never runs it).
1103
+ const meta = vaultMeta(chosenStore);
1104
+ if (meta) {
1105
+ console.log(` ${c.dim}Secret backend: ${c.reset}${c.bold}${meta.label}${c.reset}`);
1106
+ if (meta.miseTool) {
1107
+ console.log(` ${c.green}✓${c.reset} ${c.dim}${c.reset}${c.bold}kit setup${c.reset}${c.dim} will install its CLI via mise${c.reset}`);
1108
+ }
1109
+ if (meta.loginCmd) {
1110
+ const steps = meta.initCmd ? `${meta.loginCmd} && ${meta.initCmd}` : meta.loginCmd;
1111
+ console.log(` ${c.yellow}!${c.reset} ${c.dim}then authenticate (your account): ${c.reset}${c.bold}${steps}${c.reset}`);
1112
+ }
1113
+ console.log();
1114
+ }
983
1115
  return "written";
984
1116
  }
1117
+ /**
1118
+ * Brownfield context-lock offer. If the repo already talks to gcloud / vercel /
1119
+ * github but `.kit.toml` declares no `[context]`, surface the detected
1120
+ * account+project and offer to lock it. kit does NOT install or authenticate
1121
+ * these (cloud env's job) — it locks which account+project this repo is bound to,
1122
+ * the exact pairing where cross-account contamination bugs hide. Default is NO:
1123
+ * the values are the currently-active CLI state, which is what the lock exists to
1124
+ * question, so the user must confirm they're right for THIS repo first.
1125
+ */
1126
+ async function offerContextLock(configPath, nonInteractive) {
1127
+ const live = await gatherLive(process.cwd());
1128
+ if (!hasLockableContext(live))
1129
+ return;
1130
+ const block = suggestContextToml(live);
1131
+ if (!block.trim())
1132
+ return;
1133
+ console.log(`${c.bold}Detected environment${c.reset} ${c.dim}— lock account+project so kit verifies the right one each session:${c.reset}\n`);
1134
+ for (const line of block.split("\n")) {
1135
+ console.log(line.trim() === "" ? "" : ` ${c.dim}${line}${c.reset}`);
1136
+ }
1137
+ console.log(`\n${c.yellow}⚠ These are the currently-active CLI values — verify each is right for THIS repo before locking.${c.reset}`);
1138
+ if (nonInteractive) {
1139
+ console.log(`${c.dim}Non-interactive: not writing. Add the block above to .kit.toml, or run ${c.reset}${c.bold}kit context check${c.reset}${c.dim} to lock it.${c.reset}\n`);
1140
+ return;
1141
+ }
1142
+ const ok = await promptConfirm("Add this [context] lock to .kit.toml? [y/N] ", 10000, false);
1143
+ if (!ok) {
1144
+ console.log(`${c.dim}Skipped — run ${c.reset}${c.bold}kit context check${c.reset}${c.dim} later to add it.${c.reset}\n`);
1145
+ return;
1146
+ }
1147
+ const existing = readFileSync(configPath, "utf-8");
1148
+ await writeFile(configPath, existing.trimEnd() + "\n\n" + block + "\n", "utf-8");
1149
+ console.log(` ${c.green}✓${c.reset} Locked ${c.bold}[context]${c.reset} ${c.dim}→ verify with ${c.reset}${c.bold}kit context check${c.reset}\n`);
1150
+ }
985
1151
  async function cmdInit() {
986
1152
  console.log(`${c.bold}${c.cyan}kit init${c.reset}`);
987
1153
  console.log(`${c.dim}${"─".repeat(50)}${c.reset}\n`);
@@ -1003,6 +1169,11 @@ async function cmdInit() {
1003
1169
  return true; // user declined — exit 0, not an error
1004
1170
  }
1005
1171
  const config = await loadConfig(configPath);
1172
+ // Brownfield: offer to lock the already-active cloud/repo context (no install,
1173
+ // no login — just pin account+project) when none is declared yet.
1174
+ if (!config.context) {
1175
+ await offerContextLock(configPath, nonInteractive);
1176
+ }
1006
1177
  // Check if lock files exist
1007
1178
  const kitMeta = await readkitMeta();
1008
1179
  const skillsLock = await readSkillsLock();