Skip to content
SlashClone

Add demo accounts and starter content to production

@slashclone/seed-through-the-real-path

Fill a production app with demo accounts and starter content safely, through its own API or CLI instead of raw database inserts. Validation and audit logs run as for any user. Idempotent scripts with a dry run, short-lived tokens in a private file, a cleanup step that revokes them, and a guard that shows which database you are about to write to. Never prints tokens.

SkillDevOpsv0.1.0Tested
Activity
New: be the first to try it
  1. 1#!/usr/bin/env node
  2. 2// seed-guard: two safety checks to run before seeding a production app.
  3. 3//
  4. 4// target Layer env files in order (later files win), then print only the
  5. 5// host of one URL variable, so you know which database or API a
  6. 6// seeding script will reach. Never prints the URL or credentials.
  7. 7// tokens Check a seeding token file: outside the repo, mode 0600, valid
  8. 8// JSON of account name to token. Prints account names only.
  9. 9//
  10. 10// Usage:
  11. 11// node scripts/seed-guard.mjs target --env-file .env --env-file .env.production --var DATABASE_URL --expect prod
  12. 12// node scripts/seed-guard.mjs tokens --file /secure/place/seed-tokens.json
  13. 13//
  14. 14// Import readTokenFile() from your own seeding scripts so every reader of the
  15. 15// token file fails with a generic message instead of echoing its contents.
  16. 16//
  17. 17// Node 22 or newer, standard library only.
  18. 18import { readFileSync, statSync } from "node:fs";
  19. 19import { isAbsolute, relative, resolve } from "node:path";
  20. 20import { fileURLToPath } from "node:url";
  21. 21import { parseArgs, parseEnv } from "node:util";
  22. 23export class GuardError extends Error {}
  23. 25/** Merge env files in order. A later file overrides an earlier one. Values are never printed. */
  24. 26export function layerEnvFiles(files) {
  25. 27 const merged = {};
  26. 28 for (const file of files) {
  27. 29 let text;
  28. 30 try {
  29. 31 text = readFileSync(file, "utf8");
  30. 32 } catch {
  31. 33 throw new GuardError(`Could not read env file ${file}.`);
  32. 34 }
  33. 35 let parsed;
  34. 36 try {
  35. 37 parsed = parseEnv(text);
  36. 38 } catch {
  37. 39 throw new GuardError(`Could not parse env file ${file}.`);
  38. 40 }
  39. 41 Object.assign(merged, parsed);
  40. 42 }
  41. 43 return merged;
  42. 46/** The host of a URL value, or a GuardError that does not quote the value. */
  43. 47export function hostOf(value, name) {
  44. 48 if (!value) throw new GuardError(`${name} is not set in the env files given.`);
  45. 49 try {
  46. 50 const url = new URL(value);
  47. 51 if (!url.hostname) throw new Error("no host");
  48. 52 return url.hostname;
  49. 53 } catch {
  50. 54 throw new GuardError(`${name} is not a URL with a host.`);
  51. 55 }
  52. 58/**
  53. 59 * Read a seeding token file safely. Errors never include the file's contents.
  54. 60 * Returns a map of account name to token.
  55. 61 */
  56. 62export function readTokenFile(file, { repo = process.cwd(), checkMode = true } = {}) {
  57. 63 const path = resolve(file);
  58. 64 const rel = relative(resolve(repo), path);
  59. 65 if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) {
  60. 66 throw new GuardError("The token file is inside the repo. Move it outside the repo.");
  61. 67 }
  62. 68 let stats;
  63. 69 try {
  64. 70 stats = statSync(path);
  65. 71 } catch {
  66. 72 throw new GuardError("The token file does not exist or cannot be read.");
  67. 73 }
  68. 74 if (checkMode && process.platform !== "win32" && (stats.mode & 0o077) !== 0) {
  69. 75 throw new GuardError("The token file is readable by others. Run: chmod 600 on it.");
  70. 76 }
  71. 77 let data;
  72. 78 try {
  73. 79 data = JSON.parse(readFileSync(path, "utf8"));
  74. 80 } catch {
  75. 81 throw new GuardError("The token file is not valid JSON. Contents not shown.");
  76. 82 }
  77. 83 if (!data || typeof data !== "object" || Array.isArray(data)) {
  78. 84 throw new GuardError("The token file must be a JSON object of account name to token.");
  79. 85 }
  80. 86 for (const [name, token] of Object.entries(data)) {
  81. 87 if (typeof token !== "string" || token.length === 0) {
  82. 88 throw new GuardError(`The entry for ${name} is not a token string.`);
  83. 89 }
  84. 90 }
  85. 91 return data;
  86. 94function main(argv) {
  87. 95 const [command, ...rest] = argv;
  88. 96 const { values } = parseArgs({
  89. 97 args: rest,
  90. 98 options: {
  91. 99 "env-file": { type: "string", multiple: true, default: [] },
  92. 100 var: { type: "string", default: "DATABASE_URL" },
  93. 101 expect: { type: "string" },
  94. 102 file: { type: "string" },
  95. 103 repo: { type: "string", default: process.cwd() },
  96. 104 },
  97. 105 });
  98. 107 if (command === "target") {
  99. 108 if (values["env-file"].length === 0) throw new GuardError("Give at least one --env-file.");
  100. 109 const env = layerEnvFiles(values["env-file"]);
  101. 110 const host = hostOf(env[values.var], values.var);
  102. 111 console.log(`${values.var} points at host: ${host}`);
  103. 112 if (values.expect && !host.includes(values.expect)) {
  104. 113 throw new GuardError(
  105. 114 `Host does not contain "${values.expect}". Stop and check the env files.`,
  106. 115 );
  107. 116 }
  108. 117 return;
  109. 118 }
  110. 120 if (command === "tokens") {
  111. 121 if (!values.file) throw new GuardError("Give --file with the token file path.");
  112. 122 const tokens = readTokenFile(values.file, { repo: values.repo });
  113. 123 const names = Object.keys(tokens);
  114. 124 console.log(`Token file ok: ${String(names.length)} account(s): ${names.join(", ")}`);
  115. 125 return;
  116. 126 }
  117. 128 throw new GuardError("Usage: seed-guard.mjs target|tokens [options]. See the top of the file.");
  118. 131if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
  119. 132 try {
  120. 133 main(process.argv.slice(2));
  121. 134 } catch (error) {
  122. 135 // Print only our own messages. Anything else gets a generic line, so a
  123. 136 // stray error can never quote a secret into a log.
  124. 137 console.error(
  125. 138 error instanceof GuardError ? error.message : "seed-guard failed. Details hidden.",
  126. 139 );
  127. 140 process.exit(1);
  128. 141 }

Cookies that keep you signed in and remember these choices are always on. Everything else is off until you turn it on.