sandstream-kit 1.4.2 → 1.5.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";
@@ -46,7 +46,8 @@ import { getBudgetStatus, formatBudgetStatus } from "./budget.js";
46
46
  import { formatGovernanceStatus, mergeGovernanceConfigAsync } from "./governance.js";
47
47
  import { withGovernance } from "./governance-middleware.js";
48
48
  import { SKIPPED_COMMITS_LOG } from "./hooks.js";
49
- import { writeAgentConfig, detectAgentTargets } from "./agent-config.js";
49
+ import { writeAgentConfig, detectAgentTargets, installKitPermissions } from "./agent-config.js";
50
+ import { applyRecommendedHardening } from "./recommended.js";
50
51
  import { checkHooks, isGitRepository } from "./check-hooks.js";
51
52
  import { readkitMeta, readSkillsLock, readCliLock, updateSkillsLock, updateCliLock, } from "./lock.js";
52
53
  import { provisionService, listAvailableServices, getServiceInfo } from "./provision.js";
@@ -55,7 +56,7 @@ import { promptConfirm } from "./utils/prompt.js";
55
56
  import { c } from "./utils/colors.js";
56
57
  import { gatherStatus } from "./status.js";
57
58
  import { KIT_FILE, resolveConfigPath } from "./cli-shared.js";
58
- import { checkContext, applyContext, contextPrompt, gatherLive, suggestContextToml } from "./context-lock.js";
59
+ import { checkContext, applyContext, contextPrompt, gatherLive, suggestContextToml, hasLockableContext } from "./context-lock.js";
59
60
  import { cmdEnv } from "./commands/env.js";
60
61
  import { cmdAuth } from "./commands/auth.js";
61
62
  import { cmdAudit } from "./commands/audit.js";
@@ -65,6 +66,8 @@ import { resolveAllAuth } from "./service-auth.js";
65
66
  import { runDoctor } from "./doctor.js";
66
67
  import { detectStack } from "./stack-detector.js";
67
68
  import { generateToml } from "./toml-generator.js";
69
+ import { vaultMeta } from "./vault-meta.js";
70
+ import { vaultCliInstalled } from "./secret-backends.js";
68
71
  import { createPlugin } from "./create-plugin.js";
69
72
  import { cmdPlugin } from "./plugins-cli.js";
70
73
  import { cloneRepository } from "./clone.js";
@@ -629,6 +632,27 @@ async function cmdSecrets() {
629
632
  else if (skipped === "nothing-resolved") {
630
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}`);
631
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
+ }
632
656
  console.log();
633
657
  return allOk;
634
658
  });
@@ -705,10 +729,52 @@ async function cmdEscalate() {
705
729
  return false;
706
730
  });
707
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
+ }
708
753
  async function cmdSetup() {
709
754
  console.log(`${c.bold}${c.cyan}kit setup${c.reset}`);
710
755
  console.log(`${c.dim}${"─".repeat(50)}${c.reset}\n`);
711
756
  const config = await loadConfig(resolveConfigPath());
757
+ // Recommended profile: an explicit flag always wins; otherwise ASK
758
+ // interactively (the flag is just the scriptable answer to this question).
759
+ // CI/agents without a flag get the core setup — we never silently wire global
760
+ // ~/.claude hooks or the repo's git hooks without an explicit yes.
761
+ let recommended;
762
+ if (hasFlag(process.argv, "--recommended")) {
763
+ recommended = true;
764
+ }
765
+ else if (hasFlag(process.argv, "--minimal") || hasFlag(process.argv, "--no-recommended")) {
766
+ recommended = false;
767
+ }
768
+ else if (isNonInteractive()) {
769
+ recommended = false;
770
+ }
771
+ else {
772
+ recommended = await promptConfirm(`Use the recommended profile? Wires cross-harness memory hooks (in ~/.claude) + git secret-scan${config.context ? " + context-check" : ""} gates after the core steps. [Y/n] `, 10000, true);
773
+ console.log();
774
+ }
775
+ if (recommended) {
776
+ console.log(`${c.dim}Recommended profile on — memory + git hooks wired after the core steps.${c.reset}\n`);
777
+ }
712
778
  // Step 1: Install
713
779
  console.log(`${c.bold}[1/6] Install${c.reset}`);
714
780
  const installOk = await cmdInstall();
@@ -717,6 +783,13 @@ async function cmdSetup() {
717
783
  console.log(`${c.dim}Fix the issues above and run ${c.reset}${c.bold}kit setup${c.reset}${c.dim} again.${c.reset}`);
718
784
  return false;
719
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
+ }
720
793
  // Step 2: Git Hooks
721
794
  if (config.hooks && Object.keys(config.hooks).length > 0 && isGitRepository()) {
722
795
  console.log(`${c.bold}[2/6] Git Hooks${c.reset}`);
@@ -731,7 +804,39 @@ async function cmdSetup() {
731
804
  }
732
805
  // Step 4: Secrets
733
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
+ }
734
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
+ }
735
840
  // Step 5: Agent config — teach the present agent(s) to use kit. Idempotent;
736
841
  // only writes a managed block, so re-running setup leaves it unchanged.
737
842
  console.log(`${c.bold}[5/6] Agent config${c.reset}`);
@@ -745,11 +850,43 @@ async function cmdSetup() {
745
850
  console.log(` ${mark}${c.reset} ${r.agent} ${c.dim}→ ${r.file} (${r.action})${c.reset}`);
746
851
  }
747
852
  }
853
+ // Let the agent actually run kit: grant the read-only kit commands (same as
854
+ // `kit agent-config`). Without this the agent hits the permission wall.
855
+ const perms = await installKitPermissions();
856
+ if (perms.action === "created" || perms.action === "updated") {
857
+ console.log(` ${c.green}✓${c.reset} allowed ${perms.added.length} read-only kit command(s) ${c.dim}→ ${perms.file}${c.reset}`);
858
+ }
748
859
  console.log();
749
860
  // Step 6: Verify
750
861
  console.log(`${c.bold}[6/6] Verify${c.reset}`);
751
862
  const verifyOk = await cmdCheck();
752
- const allOk = installOk && loginOk && secretsOk && verifyOk;
863
+ // Recommended hardening: memory hooks + git hooks (opt-in via --recommended).
864
+ if (recommended) {
865
+ console.log(`\n${c.bold}[+] Recommended hardening${c.reset}`);
866
+ const h = await applyRecommendedHardening(config);
867
+ for (const e of h.memory.added)
868
+ console.log(` ${c.green}✓${c.reset} memory hook ${c.dim}${e}${c.reset}`);
869
+ if (h.memory.added.length === 0)
870
+ console.log(` ${c.dim}= memory hooks already wired${c.reset}`);
871
+ if (!h.memory.resolved) {
872
+ console.log(` ${c.yellow}!${c.reset} ${c.dim}memory hooks use a bare \`kit\` (kit not resolvable to an absolute path)${c.reset}`);
873
+ }
874
+ for (const r of h.hooks) {
875
+ const icon = r.action === "failed" ? `${c.red}✗` : r.action === "skipped" ? `${c.yellow}!` : `${c.green}✓`;
876
+ console.log(` ${icon}${c.reset} git ${r.hookName} ${c.dim}(${r.action})${c.reset}`);
877
+ }
878
+ console.log();
879
+ }
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;
753
890
  if (allOk) {
754
891
  console.log(`${c.bold}${c.green}Setup complete — you're ready to go! ✓${c.reset}\n`);
755
892
  }
@@ -776,8 +913,20 @@ async function cmdAgentConfig() {
776
913
  console.log(` ${mark}${c.reset} ${r.agent} ${c.dim}→ ${r.file} (${r.action})${c.reset}`);
777
914
  }
778
915
  }
779
- console.log();
780
- console.log(`${c.dim}The block is regenerated in place on re-run; edit outside the markers freely.${c.reset}`);
916
+ // Let the agent actually RUN kit: grant read-only kit commands in
917
+ // .claude/settings.json, so they don't hit the permission wall in auto mode.
918
+ const perms = await installKitPermissions();
919
+ if (perms.action === "created" || perms.action === "updated") {
920
+ console.log(`\n ${c.green}✓${c.reset} allowed ${c.bold}${perms.added.length}${c.reset} read-only kit command(s) in ${c.dim}${perms.file}${c.reset} ${c.dim}(so the agent can run them without a prompt)${c.reset}`);
921
+ }
922
+ else if (perms.action === "unchanged") {
923
+ console.log(`\n ${c.dim}= read-only kit commands already allowed in ${perms.file}${c.reset}`);
924
+ }
925
+ else if (perms.action === "failed") {
926
+ console.log(`\n ${c.yellow}!${c.reset} could not update ${perms.file}: ${perms.detail}`);
927
+ }
928
+ console.log(`\n${c.dim}Blocks regenerate in place on re-run; edit outside the markers freely. ` +
929
+ `Mutating kit commands (secrets/fix/hooks) still prompt by design.${c.reset}`);
781
930
  return !failed;
782
931
  }
783
932
  async function cmdGovernance() {
@@ -901,7 +1050,12 @@ async function generateConfigFile(configPath, nonInteractive) {
901
1050
  ]));
902
1051
  console.log();
903
1052
  }
904
- const tomlContent = generateToml(stack, { secretsStore: chosenStore });
1053
+ // Detect a Dockerfile so generateToml can provision the trivy container/IaC
1054
+ // scanner only where it applies.
1055
+ const hasDockerfile = existsSync(resolve(process.cwd(), "Dockerfile")) ||
1056
+ existsSync(resolve(process.cwd(), "docker-compose.yml")) ||
1057
+ existsSync(resolve(process.cwd(), "compose.yml"));
1058
+ const tomlContent = generateToml(stack, { secretsStore: chosenStore, hasDockerfile });
905
1059
  // Show diff preview
906
1060
  console.log(`${c.bold}Preview — .kit.toml${c.reset}\n`);
907
1061
  for (const line of tomlContent.split("\n")) {
@@ -923,8 +1077,57 @@ async function generateConfigFile(configPath, nonInteractive) {
923
1077
  }
924
1078
  await writeFile(configPath, tomlContent, "utf-8");
925
1079
  console.log(` ${c.green}✓${c.reset} Generated ${c.bold}.kit.toml${c.reset}\n`);
1080
+ // Close the loop on the vault choice: tell the user exactly what `kit setup`
1081
+ // will provision and what they still have to do themselves (login is their
1082
+ // account action — kit guides it, never runs it).
1083
+ const meta = vaultMeta(chosenStore);
1084
+ if (meta) {
1085
+ console.log(` ${c.dim}Secret backend: ${c.reset}${c.bold}${meta.label}${c.reset}`);
1086
+ if (meta.miseTool) {
1087
+ 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}`);
1088
+ }
1089
+ if (meta.loginCmd) {
1090
+ const steps = meta.initCmd ? `${meta.loginCmd} && ${meta.initCmd}` : meta.loginCmd;
1091
+ console.log(` ${c.yellow}!${c.reset} ${c.dim}then authenticate (your account): ${c.reset}${c.bold}${steps}${c.reset}`);
1092
+ }
1093
+ console.log();
1094
+ }
926
1095
  return "written";
927
1096
  }
1097
+ /**
1098
+ * Brownfield context-lock offer. If the repo already talks to gcloud / vercel /
1099
+ * github but `.kit.toml` declares no `[context]`, surface the detected
1100
+ * account+project and offer to lock it. kit does NOT install or authenticate
1101
+ * these (cloud env's job) — it locks which account+project this repo is bound to,
1102
+ * the exact pairing where cross-account contamination bugs hide. Default is NO:
1103
+ * the values are the currently-active CLI state, which is what the lock exists to
1104
+ * question, so the user must confirm they're right for THIS repo first.
1105
+ */
1106
+ async function offerContextLock(configPath, nonInteractive) {
1107
+ const live = await gatherLive(process.cwd());
1108
+ if (!hasLockableContext(live))
1109
+ return;
1110
+ const block = suggestContextToml(live);
1111
+ if (!block.trim())
1112
+ return;
1113
+ console.log(`${c.bold}Detected environment${c.reset} ${c.dim}— lock account+project so kit verifies the right one each session:${c.reset}\n`);
1114
+ for (const line of block.split("\n")) {
1115
+ console.log(line.trim() === "" ? "" : ` ${c.dim}${line}${c.reset}`);
1116
+ }
1117
+ console.log(`\n${c.yellow}⚠ These are the currently-active CLI values — verify each is right for THIS repo before locking.${c.reset}`);
1118
+ if (nonInteractive) {
1119
+ 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`);
1120
+ return;
1121
+ }
1122
+ const ok = await promptConfirm("Add this [context] lock to .kit.toml? [y/N] ", 10000, false);
1123
+ if (!ok) {
1124
+ console.log(`${c.dim}Skipped — run ${c.reset}${c.bold}kit context check${c.reset}${c.dim} later to add it.${c.reset}\n`);
1125
+ return;
1126
+ }
1127
+ const existing = readFileSync(configPath, "utf-8");
1128
+ await writeFile(configPath, existing.trimEnd() + "\n\n" + block + "\n", "utf-8");
1129
+ 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`);
1130
+ }
928
1131
  async function cmdInit() {
929
1132
  console.log(`${c.bold}${c.cyan}kit init${c.reset}`);
930
1133
  console.log(`${c.dim}${"─".repeat(50)}${c.reset}\n`);
@@ -946,6 +1149,11 @@ async function cmdInit() {
946
1149
  return true; // user declined — exit 0, not an error
947
1150
  }
948
1151
  const config = await loadConfig(configPath);
1152
+ // Brownfield: offer to lock the already-active cloud/repo context (no install,
1153
+ // no login — just pin account+project) when none is declared yet.
1154
+ if (!config.context) {
1155
+ await offerContextLock(configPath, nonInteractive);
1156
+ }
949
1157
  // Check if lock files exist
950
1158
  const kitMeta = await readkitMeta();
951
1159
  const skillsLock = await readSkillsLock();
@@ -3015,7 +3223,8 @@ const COMMAND_HELP = {
3015
3223
  "auth revoke": "Drop the elevation marker",
3016
3224
  "auth setup-totp": "Enroll TOTP secret (writes ~/.kit/totp-secret 0600)",
3017
3225
  "hooks add": "Install a built-in hook (e.g. secret-scan)",
3018
- setup: "Full pipeline: install → login → secrets → project setup",
3226
+ setup: "Full pipeline: install → login → secrets → agent config → verify",
3227
+ "setup --recommended": "Opinionated profile: setup + memory hooks + git secret-scan/context-check gates",
3019
3228
  fix: "Auto-fix what is possible",
3020
3229
  escalate: "List what needs human action",
3021
3230
  governance: "View governance status and agent access controls",