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/README.md +32 -0
- package/dist/check-security.d.ts +6 -0
- package/dist/check-security.js +148 -9
- package/dist/check-security.js.map +1 -1
- package/dist/cli.js +180 -9
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +10 -0
- package/dist/config.js.map +1 -1
- package/dist/context-lock.d.ts +8 -0
- package/dist/context-lock.js +10 -0
- package/dist/context-lock.js.map +1 -1
- package/dist/secret-backends.d.ts +3 -0
- package/dist/secret-backends.js +35 -11
- package/dist/secret-backends.js.map +1 -1
- package/dist/service-registry.d.ts +54 -0
- package/dist/service-registry.js +248 -0
- package/dist/service-registry.js.map +1 -0
- package/dist/stack-detector.js +176 -55
- package/dist/stack-detector.js.map +1 -1
- package/dist/toml-generator.d.ts +5 -0
- package/dist/toml-generator.js +100 -105
- package/dist/toml-generator.js.map +1 -1
- package/dist/vault-meta.d.ts +44 -0
- package/dist/vault-meta.js +35 -0
- package/dist/vault-meta.js.map +1 -0
- package/package.json +2 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
949
|
-
{ value: "1password", label: "1Password", hint: "interactive signin via op CLI"
|
|
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
|
-
|
|
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();
|