@starci/hfs 4.4.1 → 4.5.1

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 (66) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/ci/cli.mjs +64 -0
  3. package/ci/github.mjs +41 -0
  4. package/ci/secret.mjs +28 -0
  5. package/ci/vars.mjs +33 -0
  6. package/ci/verify.mjs +36 -0
  7. package/db/backup-receipt.mjs +129 -0
  8. package/db/dump.mjs +66 -0
  9. package/db/migrate.mjs +4 -1
  10. package/db/reset.mjs +125 -0
  11. package/emit/db-types.mjs +98 -5
  12. package/lint/run.mjs +28 -6
  13. package/package.json +2 -1
  14. package/release/ci-red.mjs +70 -0
  15. package/release/gate.mjs +98 -27
  16. package/release/index.mjs +6 -4
  17. package/release/push.mjs +1 -1
  18. package/runtime/knowledge/hfs/canon-pins.yaml +5 -5
  19. package/runtime/knowledge/hfs/rules.yaml +95 -4
  20. package/runtime/modules/kernel/failure-codes.yaml +60 -0
  21. package/runtime/modules/schemas/application-stacks.schema.yaml +134 -1
  22. package/runtime/packages/cli/src/catalog.generated.mjs +243 -20
  23. package/runtime/scripts/hfs/ci-declaration.mjs +102 -0
  24. package/runtime/scripts/hfs/rules/a15-findings.mjs +9 -0
  25. package/runtime/scripts/hfs/rules/app-workflow-findings.mjs +10 -0
  26. package/runtime/scripts/hfs/rules/ci-custody.mjs +104 -0
  27. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +6 -19
  28. package/runtime/scripts/hfs/rules/fe-build-args.mjs +142 -0
  29. package/runtime/scripts/hfs/rules/supply-chain.mjs +40 -3
  30. package/runtime/scripts/hfs/rules/workflow-contexts.mjs +103 -0
  31. package/runtime/scripts/lib/secret-patterns.mjs +2 -2
  32. package/src/main.mjs +17 -6
  33. package/stack/context.mjs +13 -1
  34. package/stack/docker-transport.mjs +93 -15
  35. package/stack/release-store.mjs +12 -7
  36. package/stack/remote.mjs +14 -6
  37. package/stack/ssh-session.mjs +178 -0
  38. package/sync/ci-commands.mjs +205 -1
  39. package/sync/index.mjs +42 -16
  40. package/sync/quality-services.mjs +61 -0
  41. package/sync/readme-badges.mjs +44 -0
  42. package/sync/stack-images.mjs +29 -3
  43. package/templates/app/ci-workflows/github/workflows/ci.yml +33 -25
  44. package/templates/app/ci-workflows/github/workflows/e2e.yml +1 -0
  45. package/templates/app/ci-workflows/github/workflows/images.yml +25 -2
  46. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +8 -2
  47. package/templates/app/ci-workflows/install-steps.yml +23 -0
  48. package/templates/app/ci-workflows/jest-cache.yml +8 -0
  49. package/templates/app/ci-workflows/lint-base.yml +16 -0
  50. package/templates/app/ci-workflows/sonar-steps.yml +3 -9
  51. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +36 -36
  52. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -1
  53. package/templates/app/ci-workflows-lite/github/workflows/images.yml +25 -2
  54. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +8 -2
  55. package/templates/app/gitignore +2 -0
  56. package/templates/app/gitignore-lite +2 -0
  57. package/templates/app/quality-config/codecov.yml +6 -6
  58. package/templates/app/quality-config/sonar-project.properties +1 -0
  59. package/templates/app/readme-badges/README.md +1 -0
  60. package/templates/app/skeleton/.github/dependabot.yml +1 -14
  61. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +62 -3
  62. package/templates/app/skeleton/README.md +6 -0
  63. package/templates/app/skeleton-lite/.github/dependabot.yml +1 -14
  64. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +58 -3
  65. package/templates/app/skeleton-lite/README.md +6 -0
  66. package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +53 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.5.1 - 2026-10-18
4
+ - Fixed: managed CI base refs, public build values, caching and timeouts; optional quality service handling, README formatting and Supabase migration-history parsing.
5
+
6
+ ## 4.5.0 - 2026-10-17
7
+ - Added: consumer-class CI declarations, `app ci` custody verbs and R296-R298; manifest-driven quality files and split cached CI with immutable action pins.
8
+ - Added: release audit/local Sonar and main-CI protection; encrypted outside-repository DB dump and armed linked reset with backup/auth/storage validation.
9
+ - Fixed: DB types refuse stale migration history.
10
+
3
11
  ## 4.4.1 - 2026-10-17
4
12
  - Changed: the bundled runtime copies follow the runtime of 1.0.0-alpha.19 (owner ruling no-runtime-specs, 2026-10-17): the failure codes, the slot manifest (`exampleNa`) and the rule catalog (`specNa`, `exampleNa`) carry the removal of the runtime's own specs; package behavior is unchanged. The managed codecov.yml, the CI coverage upload and the hfs sync render stay (Codecov is kept for apps and examples); internal exports that only the deleted runtime specs read are no longer exported.
5
13
 
package/ci/cli.mjs ADDED
@@ -0,0 +1,64 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseYaml } from '../runtime/engine/yaml.mjs';
4
+ import { readCiDeclaration, sealedKeyNames } from '../runtime/scripts/hfs/ci-declaration.mjs';
5
+ import { github, refuse } from './github.mjs';
6
+ import { setSecret } from './secret.mjs';
7
+ import { setVariable, syncVariables } from './vars.mjs';
8
+ import { verifyCi } from './verify.mjs';
9
+
10
+ const COMMANDS = {
11
+ 'secret set': ['env', 'dependabot', 'repo'],
12
+ 'var set': ['env', 'value', 'repo'],
13
+ 'var sync': ['execute', 'repo'],
14
+ verify: ['json', 'repo'],
15
+ };
16
+ const BOOLEAN = new Set(['dependabot', 'execute', 'json']);
17
+
18
+ /** Invalid options never echo argv, because an extra argument may itself contain a secret. */
19
+ function parse(argv) {
20
+ const command = argv[0] === 'verify' ? 'verify' : argv.slice(0, 2).join(' ');
21
+ const allowed = COMMANDS[command];
22
+ if (!allowed) throw refuse('CI_USAGE', 'use secret set, var set, var sync or verify');
23
+ const positional = [], options = {};
24
+ for (let i = command === 'verify' ? 1 : 2; i < argv.length; i += 1) {
25
+ const arg = argv[i], match = /^--([^=]+)(?:=(.*))?$/s.exec(arg);
26
+ if (!match && !arg.startsWith('-')) { positional.push(arg); continue; }
27
+ const key = match?.[1];
28
+ if (!allowed.includes(key) || Object.hasOwn(options, key)) throw refuse(command === 'secret set' ? 'CI_VALUE_ARGV' : 'CI_USAGE', 'unsupported or repeated option');
29
+ if (BOOLEAN.has(key)) {
30
+ if (match[2] !== undefined) throw refuse('CI_USAGE', 'boolean flags take no value');
31
+ options[key] = true;
32
+ } else {
33
+ const value = match[2] ?? argv[++i];
34
+ if (value === undefined || value.startsWith('--') || (key !== 'value' && !value.length)) throw refuse('CI_USAGE', 'an option value is required');
35
+ options[key] = value;
36
+ }
37
+ }
38
+ const named = command.endsWith(' set');
39
+ if (positional.length !== (named ? 1 : 0)) throw refuse(command === 'secret set' && positional.length > 1 ? 'CI_VALUE_ARGV' : 'CI_USAGE', 'provide the declared name only for set');
40
+ if (named && (!/^[A-Z][A-Z0-9_]*$/.test(positional[0]) || positional[0].startsWith('GITHUB_'))) throw refuse('CI_NAME_UNDECLARED', 'a declared uppercase CI name is required');
41
+ if (options.dependabot && options.env) throw refuse('CI_SCOPE_MISMATCH', 'Dependabot secrets have repository scope');
42
+ return { command, options, name: positional[0] };
43
+ }
44
+
45
+ /** Manifest and sealed key reads remain offline; only github.mjs may invoke the GitHub CLI. */
46
+ export async function ciMain(argv, { cwd = process.cwd(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), stdin, env = process.env, tools = {} } = {}) {
47
+ try {
48
+ const parsed = parse(argv);
49
+ let declaration, sealed;
50
+ try {
51
+ declaration = readCiDeclaration(parseYaml(fs.readFileSync(path.join(cwd, '.starcistacks/application-stacks.yaml'), 'utf8')));
52
+ sealed = sealedKeyNames(cwd, { fs });
53
+ } catch { throw refuse('CI_DECLARATION_INVALID', 'the manifest or sealed key names could not be read'); }
54
+ if (declaration.problems.length) throw refuse('CI_DECLARATION_INVALID', 'correct the CI declaration before using GitHub');
55
+ const context = { ...parsed, declaration, sealed, gh: github({ cwd, env, spawn: tools.spawn }), stdin, stdout };
56
+ if (parsed.command === 'secret set') return await setSecret(context);
57
+ if (parsed.command === 'var set') return setVariable(context);
58
+ if (parsed.command === 'var sync') return syncVariables(context);
59
+ return verifyCi(context);
60
+ } catch (error) {
61
+ stderr(`${error.code ? error.message : 'CI_USAGE: CI command could not complete'}\n`);
62
+ return 2;
63
+ }
64
+ }
package/ci/github.mjs ADDED
@@ -0,0 +1,41 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ /** Refusals carry only fixed public diagnostics, never child output or secret input. */
4
+ export function refuse(code, message) {
5
+ const error = new Error(`${code}: ${message}`);
6
+ error.code = code;
7
+ return error;
8
+ }
9
+
10
+ /** All GitHub access uses argument arrays and captured output; secret bodies travel only on stdin. */
11
+ export function github({ cwd, spawn = spawnSync, env = process.env }) {
12
+ function run(args, input) {
13
+ let result;
14
+ try { result = spawn('gh', args, { cwd, env, encoding: 'utf8', shell: false, stdio: ['pipe', 'pipe', 'pipe'], ...(input === undefined ? {} : { input }) }); }
15
+ catch { throw refuse('CI_GITHUB_UNREADABLE', 'GitHub CLI could not run; check installation and authentication'); }
16
+ if (result.error || result.status !== 0) throw refuse('CI_GITHUB_UNREADABLE', 'GitHub CLI failed; check authentication and repository access');
17
+ return result.stdout;
18
+ }
19
+ function json(args) {
20
+ try { return JSON.parse(run(args)); }
21
+ catch (error) {
22
+ if (error.code) throw error;
23
+ throw refuse('CI_GITHUB_UNREADABLE', 'GitHub CLI returned invalid JSON');
24
+ }
25
+ }
26
+ function repository(explicit) {
27
+ const repo = explicit ?? json(['repo', 'view', '--json', 'nameWithOwner'])?.nameWithOwner;
28
+ if (typeof repo !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw refuse('CI_GITHUB_UNREADABLE', 'a repository owner/name is required');
29
+ return repo;
30
+ }
31
+ return { run, json, repository };
32
+ }
33
+
34
+ /** Repository, environment and Dependabot stores are distinct even when their names coincide. */
35
+ export function scopeArgs(options) {
36
+ return options.dependabot ? ['--app', 'dependabot'] : options.env ? ['--env', options.env] : [];
37
+ }
38
+
39
+ export function scopeName(options) {
40
+ return options.dependabot ? 'dependabot' : options.env ? `environment:${options.env}` : 'repository';
41
+ }
package/ci/secret.mjs ADDED
@@ -0,0 +1,28 @@
1
+ import fs from 'node:fs';
2
+ import { refuse, scopeArgs, scopeName } from './github.mjs';
3
+
4
+ /** A declared name must belong to the requested store before a secret body is read. */
5
+ export function declaredEntry(entries, name, options) {
6
+ const entry = entries.find((item) => item.name === name);
7
+ if (!entry) throw refuse('CI_NAME_UNDECLARED', 'the name is not declared for this CI class');
8
+ const environment = entry.scope === 'environment' ? entry.environment : undefined;
9
+ if (environment !== options.env || (options.dependabot && options.env)) throw refuse('CI_SCOPE_MISMATCH', 'the requested scope differs from the declaration');
10
+ return entry;
11
+ }
12
+
13
+ /** Secret input is read once, stripped of one trailing newline, and passed only to the child stdin. */
14
+ export async function setSecret({ declaration, sealed, gh, options, name, stdin, stdout }) {
15
+ if (sealed.some((item) => item.key === name)) throw refuse('CI_SECRET_SEALED', 'the name belongs to a sealed app secret');
16
+ declaredEntry(options.dependabot ? declaration.dependabotSecrets : declaration.secrets, name, options);
17
+ let value;
18
+ if (stdin === undefined) {
19
+ if (process.stdin.isTTY) throw refuse('CI_VALUE_EMPTY', 'a secret value is required on stdin');
20
+ value = fs.readFileSync(0, 'utf8');
21
+ } else value = typeof stdin === 'function' ? await stdin() : stdin;
22
+ value = String(value ?? '').replace(/\r?\n$/, '');
23
+ if (!value.length) throw refuse('CI_VALUE_EMPTY', 'a secret value is required on stdin');
24
+ const repo = gh.repository(options.repo);
25
+ gh.run(['secret', 'set', name, ...scopeArgs(options), '--repo', repo], value);
26
+ stdout(`set secret ${name} (${scopeName(options)})\n`);
27
+ return 0;
28
+ }
package/ci/vars.mjs ADDED
@@ -0,0 +1,33 @@
1
+ import { refuse, scopeArgs, scopeName } from './github.mjs';
2
+ import { declaredEntry } from './secret.mjs';
3
+
4
+ /** Public CI values may come from argv or the manifest, but two supplied values must agree. */
5
+ function valueOf(entry, supplied) {
6
+ const declared = entry.value === undefined ? undefined : String(entry.value);
7
+ if (supplied !== undefined && declared !== undefined && supplied !== declared) throw refuse('CI_VAR_DRIFT', 'the supplied value differs from the manifest');
8
+ const value = supplied ?? declared;
9
+ if (value === undefined) throw refuse('CI_VALUE_EMPTY', 'a public variable value is required');
10
+ return value;
11
+ }
12
+
13
+ function writeVariable(gh, repo, entry, value, options, stdout) {
14
+ gh.run(['variable', 'set', entry.name, '--body', value, ...scopeArgs(options), '--repo', repo]);
15
+ stdout(`set variable ${entry.name} (${scopeName(options)})\n`);
16
+ }
17
+
18
+ export function setVariable({ declaration, gh, options, name, stdout }) {
19
+ const entry = declaredEntry(declaration.vars, name, options), value = valueOf(entry, options.value);
20
+ writeVariable(gh, gh.repository(options.repo), entry, value, options, stdout);
21
+ return 0;
22
+ }
23
+
24
+ /** Sync validates the whole plan first and performs no GitHub access until execution is requested. */
25
+ export function syncVariables({ declaration, gh, options, stdout }) {
26
+ const plan = declaration.vars.map((entry) => ({ entry, value: valueOf(entry), options: { env: entry.scope === 'environment' ? entry.environment : undefined } }));
27
+ const repo = options.execute && plan.length ? gh.repository(options.repo) : undefined;
28
+ for (const item of plan) {
29
+ if (options.execute) writeVariable(gh, repo, item.entry, item.value, item.options, stdout);
30
+ else stdout(`would set variable ${item.entry.name} (${scopeName(item.options)})\n`);
31
+ }
32
+ return 0;
33
+ }
package/ci/verify.mjs ADDED
@@ -0,0 +1,36 @@
1
+ import { refuse, scopeArgs, scopeName } from './github.mjs';
2
+
3
+ /** Verification reports names and scopes only; values are compared in memory and excluded from findings. */
4
+ export function verifyCi({ declaration, sealed, gh, options, stdout }) {
5
+ const repo = gh.repository(options.repo), stores = [];
6
+ const environments = [...new Set([...declaration.secrets, ...declaration.vars].filter((item) => item.scope === 'environment').map((item) => item.environment))].sort();
7
+ for (const scope of [{}, ...environments.map((env) => ({ env })), { dependabot: true }]) {
8
+ for (const kind of scope.dependabot ? ['secret'] : ['secret', 'variable']) {
9
+ const rows = gh.json([kind, 'list', '--json', kind === 'secret' ? 'name' : 'name,value', ...scopeArgs(scope), '--repo', repo]);
10
+ if (!Array.isArray(rows) || rows.some((row) => typeof row?.name !== 'string' || (kind === 'variable' && typeof row.value !== 'string'))) throw refuse('CI_GITHUB_UNREADABLE', 'GitHub CLI returned an invalid name list');
11
+ stores.push({ scope, kind, rows });
12
+ }
13
+ }
14
+ const findings = [], sealedNames = new Set(sealed.map((item) => item.key));
15
+ const declarations = (store) => store.scope.dependabot ? declaration.dependabotSecrets : store.kind === 'secret' ? declaration.secrets : declaration.vars;
16
+ const matches = (entry, scope) => (entry.scope === 'environment' ? entry.environment : undefined) === scope.env;
17
+ const add = (code, name, scope) => findings.push({ code, name, scope: scopeName(scope) });
18
+ for (const store of stores) {
19
+ const entries = declarations(store), actual = new Map(store.rows.map((row) => [row.name, row]));
20
+ for (const entry of entries.filter((item) => matches(item, store.scope))) {
21
+ const row = actual.get(entry.name);
22
+ if (!row && !(store.kind === 'secret' && entry.optional)) add(store.kind === 'secret' ? 'CI_SECRET_MISSING' : 'CI_VAR_MISSING', entry.name, store.scope);
23
+ else if (row && store.kind === 'variable' && entry.value !== undefined && row.value !== String(entry.value)) add('CI_VAR_DRIFT', entry.name, store.scope);
24
+ }
25
+ for (const row of store.rows) {
26
+ if (store.kind === 'secret' && sealedNames.has(row.name)) add('APP_SECRET_IN_CI', row.name, store.scope);
27
+ if (entries.some((entry) => entry.name === row.name && matches(entry, store.scope))) continue;
28
+ const sameKind = store.kind === 'secret' ? [...declaration.secrets, ...declaration.dependabotSecrets] : declaration.vars;
29
+ add(sameKind.some((entry) => entry.name === row.name) ? 'CI_SCOPE_MISMATCH' : store.kind === 'secret' ? 'CI_SECRET_UNDECLARED' : 'CI_VAR_UNDECLARED', row.name, store.scope);
30
+ }
31
+ }
32
+ if (options.json) stdout(`${JSON.stringify({ ok: findings.length === 0, findings }, null, 2)}\n`);
33
+ else if (!findings.length) stdout('CI verification passed\n');
34
+ else for (const finding of findings) stdout(`${finding.code}: ${finding.name} (${finding.scope})\n`);
35
+ return findings.length ? 1 : 0;
36
+ }
@@ -0,0 +1,129 @@
1
+ // backup-receipt.mjs - encrypted database backup custody, shared by dump and reset; tool output never enters diagnostics.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { parseYaml } from '../runtime/engine/yaml.mjs';
6
+ import { readDeclaration } from '../release/index.mjs';
7
+ import { resolveTarget, runCli as migrationCli, targetArgs } from './migrate.mjs';
8
+ import { failure, refusal } from '../stack/errors.mjs';
9
+
10
+ export const RECEIPT_SCHEMA = 'starci/db-backup@1';
11
+ export function sha256(file) {
12
+ const hash = createHash('sha256');
13
+ const fd = fs.openSync(file, 'r');
14
+ const chunk = Buffer.alloc(1024 * 1024);
15
+ try {
16
+ let bytes;
17
+ while ((bytes = fs.readSync(fd, chunk, 0, chunk.length, null)) > 0) hash.update(chunk.subarray(0, bytes));
18
+ } finally { fs.closeSync(fd); }
19
+ return hash.digest('hex');
20
+ }
21
+ const inside = (root, file) => { const rel = path.relative(root, file); return rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel)); };
22
+
23
+ /** Backup/restore SQL can hold row values, so even the migration runner's scrubbed stderr must be withheld. */
24
+ export function runCli(session, args) {
25
+ try { return migrationCli(session, args); }
26
+ catch { throw failure('DB_BACKUP_CLI_FAILED', `supabase ${args.slice(0, 2).join(' ')} failed; output withheld`); }
27
+ }
28
+
29
+ /** Resolves existing ancestors before checking containment, including symlinked output directories and Git worktree marker files. */
30
+ export function outsideRepository(root, output) {
31
+ let ancestor = path.resolve(output);
32
+ const tail = [];
33
+ while (!fs.existsSync(ancestor)) { tail.unshift(path.basename(ancestor)); ancestor = path.dirname(ancestor); }
34
+ const resolved = path.join(fs.realpathSync(ancestor), ...tail);
35
+ if (inside(fs.realpathSync(root), resolved)) throw refusal('DB_DUMP_INSIDE_REPO', '--out must be outside the app repository and every Git worktree');
36
+ for (let dir = resolved; ; dir = path.dirname(dir)) {
37
+ if (fs.existsSync(path.join(dir, '.git'))) throw refusal('DB_DUMP_INSIDE_REPO', '--out must be outside every Git worktree');
38
+ if (dir === path.dirname(dir)) break;
39
+ }
40
+ return resolved;
41
+ }
42
+
43
+ /** Uses migrate's linked/password/URL resolution; local is explicitly selected and never silently defaults to linked. */
44
+ export function backupSession(ctx, options) {
45
+ const database = readDeclaration(ctx.cwd).database ?? {};
46
+ const selections = [options.linked === true, options.local === true, options.urlEnv !== undefined].filter(Boolean).length;
47
+ if (selections !== 1) throw refusal('DB_USAGE', 'select exactly one of --linked, --local or --url-env');
48
+ const target = options.local ? { kind: 'local', ref: null, env: {}, secrets: [], how: 'local database' }
49
+ : resolveTarget(ctx, { projectRef: null, password: null, ...database }, options);
50
+ if (target.kind === 'linked' && !/^[a-z0-9]{10,40}$/u.test(target.ref)) throw refusal('DB_NOT_LINKED', 'linked project ref is invalid');
51
+ const project = path.basename(ctx.cwd).replace(/[^A-Za-z0-9_-]/gu, '-') || 'database';
52
+ return { ctx, target, database, project };
53
+ }
54
+
55
+ export const backupArgs = (session) => session.target.kind === 'local' ? ['--local', '--workdir', session.ctx.cwd] : targetArgs(session.target, session.ctx.cwd);
56
+
57
+ /** Queries via the same injected Supabase runner; malformed output is withheld. */
58
+ export function queryRows(session, sql) {
59
+ let rows;
60
+ try { rows = JSON.parse(runCli(session, ['db', 'query', sql, '--output', 'json', '--agent', 'no', ...backupArgs(session)]).stdout); }
61
+ catch { throw failure('DB_BACKUP_QUERY_FAILED', 'database inventory query failed; output withheld'); }
62
+ if (!Array.isArray(rows) || rows.some((row) => !row || typeof row !== 'object' || Array.isArray(row))) throw failure('DB_BACKUP_QUERY_FAILED', 'database inventory is not a rows array');
63
+ return rows;
64
+ }
65
+
66
+ /** Executes local tar/age through ctx.spawn, the same seam used by the stack's age installer; never relays tool errors containing data. */
67
+ export function backupTool(ctx, command, args, options = {}) {
68
+ const result = ctx.spawn(command, args, { cwd: ctx.cwd, env: ctx.env, ...options });
69
+ if (result.error || result.status !== 0) throw failure('DB_BACKUP_TOOL_FAILED', `${command} failed; output withheld`);
70
+ return result;
71
+ }
72
+
73
+ /** Reads public recipients only from creation_rules age fields, including nested SOPS key_groups. */
74
+ export function backupRecipients(root) {
75
+ let rules;
76
+ try { rules = parseYaml(fs.readFileSync(path.join(root, '.sops.yaml'), 'utf8')).creation_rules; } catch { rules = []; }
77
+ const found = [];
78
+ const visit = (value) => {
79
+ if (!value || typeof value !== 'object') return;
80
+ for (const [key, item] of Object.entries(value)) {
81
+ if (key === 'age') found.push(...(JSON.stringify(item).match(/age1[0-9a-z]{50,}/gu) ?? []));
82
+ else if (typeof item === 'object') visit(item);
83
+ }
84
+ };
85
+ visit(rules);
86
+ if (!found.length) throw refusal('DB_BACKUP_NO_RECIPIENT', 'no age recipient in .sops.yaml creation_rules');
87
+ return [...new Set(found)];
88
+ }
89
+
90
+ /** Header and size checks prove an age envelope exists; proving decryption requires the owner's identity separately. */
91
+ function checkArchive(file) {
92
+ const size = fs.statSync(file).size;
93
+ const fd = fs.openSync(file, 'r');
94
+ const header = Buffer.alloc(22);
95
+ try { fs.readSync(fd, header, 0, header.length, 0); } finally { fs.closeSync(fd); }
96
+ if (size < 100 || !header.toString('ascii').startsWith('age-encryption.org/v1\n')) throw failure('DB_BACKUP_ARCHIVE_INVALID', 'encrypted archive has no valid age header or payload');
97
+ return size;
98
+ }
99
+
100
+ export function encryptFile(ctx, input, output, recipients) {
101
+ backupTool(ctx, 'age', [...recipients.flatMap((recipient) => ['-r', recipient]), '-o', output, input]);
102
+ return checkArchive(output);
103
+ }
104
+
105
+ /** Validates a complete, recent, full-data receipt and its encrypted bytes before any destructive reset call. */
106
+ export function validateReceipt(session, receiptPath) {
107
+ if (!receiptPath) throw refusal('DB_RESET_BACKUP_MISSING', '--backup must name an encrypted backup receipt');
108
+ let receipt;
109
+ try { receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8')); } catch { throw refusal('DB_RESET_BACKUP_INVALID', 'backup receipt is missing or invalid JSON'); }
110
+ const valid = receipt && receipt.schema === RECEIPT_SCHEMA && receipt.target === 'linked' && receipt.schemaOnly === false
111
+ && typeof receipt.project === 'string' && typeof receipt.ref === 'string' && typeof receipt.file === 'string'
112
+ && path.basename(receipt.file) === receipt.file && receipt.file.endsWith('.age') && Number.isSafeInteger(receipt.bytes) && receipt.bytes >= 100
113
+ && /^[a-f0-9]{64}$/u.test(receipt.sha256 ?? '') && Array.isArray(receipt.parts)
114
+ && ['roles.sql', 'schema.sql', 'auth-storage-schema.sql', 'data.sql', 'auth-storage-data.sql', 'rowcounts.json'].every((part) => receipt.parts.includes(part))
115
+ && Number.isSafeInteger(receipt.rowcounts?.tables) && receipt.rowcounts.tables >= 0 && /^\d+$/u.test(receipt.rowcounts?.rows ?? '')
116
+ && typeof receipt.at === 'string' && Number.isFinite(Date.parse(receipt.at));
117
+ if (!valid) throw refusal('DB_RESET_BACKUP_INVALID', 'backup receipt is not a full starci/db-backup@1 linked backup');
118
+ if (receipt.ref !== session.target.ref) throw refusal('DB_RESET_BACKUP_PROJECT', 'backup targets a different project ref');
119
+ const maxAge = session.database.reset?.backupMaxAgeHours ?? 24;
120
+ if (!Number.isFinite(maxAge) || maxAge <= 0) throw refusal('DB_CONFIG_INVALID', 'database.reset.backupMaxAgeHours must be positive');
121
+ const age = session.ctx.now() - Date.parse(receipt.at);
122
+ if (age < 0 || age > maxAge * 3600000) throw refusal('DB_RESET_BACKUP_STALE', 'backup is future-dated or older than the allowed backup age');
123
+ const file = path.resolve(path.dirname(receiptPath), receipt.file);
124
+ if (!fs.existsSync(file)) throw refusal('DB_RESET_BACKUP_ARCHIVE_MISSING', 'backup archive is absent');
125
+ try {
126
+ if (checkArchive(file) !== receipt.bytes || sha256(file) !== receipt.sha256) throw new Error('mismatch');
127
+ } catch { throw refusal('DB_RESET_BACKUP_HASH', 'backup archive size, header or sha256 differs from the receipt'); }
128
+ return receipt;
129
+ }
package/db/dump.mjs ADDED
@@ -0,0 +1,66 @@
1
+ // dump.mjs - full Supabase backup, encrypted outside Git; every plaintext part is removed even when a tool fails.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { runVerb } from '../release/index.mjs';
5
+ import { failure, refusal } from '../stack/errors.mjs';
6
+ import { RECEIPT_SCHEMA, outsideRepository, backupSession, backupArgs, backupRecipients, backupTool, encryptFile, queryRows, sha256, runCli } from './backup-receipt.mjs';
7
+
8
+ /** Counts every ordinary table with quoted identifiers in a read-only transaction, including auth and storage. */
9
+ export function rowcounts(session) {
10
+ const tables = queryRows(session, "select schemaname, tablename from pg_catalog.pg_tables where schemaname not in ('pg_catalog', 'information_schema') order by schemaname, tablename;");
11
+ const quote = (name) => `"${String(name).replaceAll('"', '""')}"`;
12
+ return tables.map((table) => {
13
+ if (typeof table.schemaname !== 'string' || typeof table.tablename !== 'string') throw failure('DB_BACKUP_QUERY_FAILED', 'invalid table inventory');
14
+ const rows = queryRows(session, `begin read only; select count(*)::text as count from ${quote(table.schemaname)}.${quote(table.tablename)}; commit;`);
15
+ if (rows.length !== 1 || !/^\d+$/u.test(String(rows[0].count))) throw failure('DB_BACKUP_QUERY_FAILED', 'invalid rowcount');
16
+ return { schema: table.schemaname, table: table.tablename, count: String(rows[0].count) };
17
+ });
18
+ }
19
+
20
+ /** Writes only ciphertext and its receipt permanently; a receipt is published only after header, size and hash checks. */
21
+ function dumpDatabase(ctx, options = {}) {
22
+ if (!options.out) throw refusal('DB_USAGE', '--out is required');
23
+ const out = outsideRepository(ctx.cwd, path.resolve(ctx.cwd, options.out));
24
+ const session = backupSession(ctx, options);
25
+ const recipients = backupRecipients(ctx.cwd);
26
+ fs.mkdirSync(out, { recursive: true, mode: 0o700 });
27
+ const at = new Date(ctx.now()).toISOString();
28
+ const stamp = at.replace(/[-:.]/gu, '');
29
+ const name = `${session.project}-${stamp}`;
30
+ const dir = path.join(out, name);
31
+ fs.mkdirSync(dir, { mode: 0o700 });
32
+ const scratch = path.join(dir, '.plaintext');
33
+ const archive = path.join(dir, `${name}.tar.age`);
34
+ const receiptPath = path.join(dir, `${name}.receipt.json`);
35
+ let complete = false;
36
+ try {
37
+ fs.mkdirSync(scratch, { mode: 0o700 });
38
+ const jobs = [['roles.sql', ['--role-only']], ['schema.sql', []], ['auth-storage-schema.sql', ['--schema', 'auth,storage']]];
39
+ if (!options.schemaOnly) jobs.push(['data.sql', ['--data-only']], ['auth-storage-data.sql', ['--data-only', '--schema', 'auth,storage']]);
40
+ for (const [file, flags] of jobs) runCli(session, ['db', 'dump', ...flags, '--file', path.join(scratch, file), ...backupArgs(session)]);
41
+ const counts = rowcounts(session);
42
+ fs.writeFileSync(path.join(scratch, 'rowcounts.json'), `${JSON.stringify(counts, null, 2)}\n`, { mode: 0o600 });
43
+ const parts = [...jobs.map(([file]) => file), 'rowcounts.json'];
44
+ const tar = path.join(scratch, 'backup.tar');
45
+ backupTool(ctx, 'tar', ['-cf', tar, '-C', scratch, ...parts]);
46
+ const bytes = encryptFile(ctx, tar, archive, recipients);
47
+ const receipt = { schema: RECEIPT_SCHEMA, project: session.project, target: session.target.kind, ref: session.target.ref ?? null,
48
+ at, file: path.basename(archive), bytes, sha256: sha256(archive), parts,
49
+ rowcounts: { tables: counts.length, rows: counts.reduce((sum, row) => sum + BigInt(row.count), 0n).toString(), tablesByName: counts }, schemaOnly: options.schemaOnly === true };
50
+ fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
51
+ complete = true;
52
+ return { ok: true, lines: [JSON.stringify(receipt, null, 2), `receipt: ${receiptPath}`, 'age header, size and sha256 verified; decryption requires the owner identity'], data: { receipt, receiptPath } };
53
+ } finally {
54
+ fs.rmSync(scratch, { recursive: true, force: true });
55
+ if (!complete) { fs.rmSync(archive, { force: true }); fs.rmSync(receiptPath, { force: true }); }
56
+ }
57
+ }
58
+
59
+ export function dbDumpMain(argv, io = {}) {
60
+ return runVerb({ label: 'starci app db dump', name: 'db dump', code: 'DB_USAGE', io, argv,
61
+ spec: { boolean: ['--linked', '--local', '--schema-only', '--json'], value: ['--url-env', '--out'] },
62
+ run: (ctx, flags, positional) => {
63
+ if (positional.length) throw refusal('DB_USAGE', 'dump takes flags only');
64
+ return dumpDatabase(ctx, { linked: flags.linked, local: flags.local, urlEnv: flags['url-env'], out: flags.out, schemaOnly: flags['schema-only'] });
65
+ } });
66
+ }
package/db/migrate.mjs CHANGED
@@ -43,6 +43,8 @@ const POST_CHECKS = {
43
43
  };
44
44
  const postOk = (value) => plain(value) && Object.keys(value).every((key) => POST_FIELDS.includes(key) && POST_CHECKS[key](value[key]));
45
45
 
46
+ const resetOk = (value) => plain(value) && Object.keys(value).every((key) => key === 'backupMaxAgeHours' && Number.isFinite(value[key]) && value[key] > 0);
47
+
46
48
  const CHECKS = {
47
49
  migrationsDir: [dirOk, 'must be a relative directory inside the app'],
48
50
  archiveDir: [dirOk, 'must be a relative directory inside the app'],
@@ -53,6 +55,7 @@ const CHECKS = {
53
55
  restamp: [listOf(pairOk), 'must be a list of { from, to } fourteen-digit stamps'],
54
56
  password: [passwordOk, 'must be { env, member, key } naming a sealed value'],
55
57
  postCutover: [postOk, `must use only ${POST_FIELDS.join(', ')} with valid values`],
58
+ reset: [resetOk, 'must be a mapping with only optional finite positive backupMaxAgeHours'],
56
59
  };
57
60
 
58
61
  /** Judges the `database:` block. Returns { config } or { problems }; `baseline` is the one required field. */
@@ -66,7 +69,7 @@ function normalizeDatabase(block) {
66
69
  if (block.baseline === undefined) problems.push('database.baseline: is required');
67
70
  if (problems.length > 0) return { problems };
68
71
  const config = {
69
- migrationsDir: 'supabase/migrations', archiveDir: null, standard: null, heldBack: [], projectRef: null, restamp: [], password: null, postCutover: null, ...block,
72
+ migrationsDir: 'supabase/migrations', archiveDir: null, standard: null, heldBack: [], projectRef: null, restamp: [], password: null, postCutover: null, reset: null, ...block,
70
73
  };
71
74
  return { config };
72
75
  }
package/db/reset.mjs ADDED
@@ -0,0 +1,125 @@
1
+ // reset.mjs - linked reset requires explicit custody choices, a recent verified encrypted backup and the migration arming rule.
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { runVerb } from '../release/index.mjs';
6
+ import { requireArmed } from './migrate.mjs';
7
+ import { failure, refusal } from '../stack/errors.mjs';
8
+ import { backupSession, backupArgs, backupRecipients, backupTool, encryptFile, queryRows, validateReceipt, runCli } from './backup-receipt.mjs';
9
+
10
+ /** Metadata fingerprints allow verification without printing or retaining rows, object keys or auth identities in the result. */
11
+ function inventory(session, schema) {
12
+ const tables = queryRows(session, `select tablename from pg_catalog.pg_tables where schemaname = '${schema}' and tablename not in ('schema_migrations', 'migrations') order by tablename;`);
13
+ return tables.map(({ tablename }) => {
14
+ if (typeof tablename !== 'string' || !/^[a-z_][a-z0-9_]*$/u.test(tablename)) throw failure('DB_RESET_VERIFY_FAILED', 'invalid preservation table inventory');
15
+ const rows = queryRows(session, `begin read only; select count(*)::text as count, md5(coalesce(string_agg(md5(row_to_json(t)::text), '' order by md5(row_to_json(t)::text)), '')) as hash from "${schema}"."${tablename}" t; commit;`);
16
+ if (rows.length !== 1 || !/^\d+$/u.test(String(rows[0].count)) || !/^[a-f0-9]{32}$/u.test(rows[0].hash ?? '')) throw failure('DB_RESET_VERIFY_FAILED', 'invalid preservation fingerprint');
17
+ return { table: tablename, count: String(rows[0].count), hash: rows[0].hash };
18
+ });
19
+ }
20
+
21
+ /** age reads identity bytes on stdin or the configured identity file; neither identity nor SQL is placed in argv or diagnostics. */
22
+ function decryptPart(ctx, encrypted, plaintext) {
23
+ const key = ctx.env.SOPS_AGE_KEY;
24
+ const identity = key ? '-' : ctx.env.SOPS_AGE_KEY_FILE;
25
+ if (!identity) throw refusal('DB_RESET_IDENTITY_MISSING', '--auth/--storage keep needs SOPS_AGE_KEY or SOPS_AGE_KEY_FILE to restore the temporary encrypted part');
26
+ backupTool(ctx, 'age', ['--decrypt', '--identity', identity, '-o', plaintext, encrypted], key ? { input: `${key}\n` } : {});
27
+ }
28
+
29
+ function preservePart(session, scratch, schema, recipients) {
30
+ const before = inventory(session, schema);
31
+ const plain = path.join(scratch, `${schema}.sql`);
32
+ const encrypted = `${plain}.age`;
33
+ runCli(session, ['db', 'dump', '--data-only', '--schema', schema, '--exclude', `${schema}.schema_migrations,${schema}.migrations`, '--file', plain, ...backupArgs(session)]);
34
+ encryptFile(session.ctx, plain, encrypted, recipients);
35
+ fs.rmSync(plain, { force: true });
36
+ // Prove the selected identity can restore this exact part before the destructive call.
37
+ decryptPart(session.ctx, encrypted, plain);
38
+ fs.rmSync(plain, { force: true });
39
+ return { schema, before, encrypted, plain };
40
+ }
41
+
42
+ function restorePart(session, part) {
43
+ // Supabase retains some managed tables. Replace their data to avoid duplicate users, buckets or migration-independent metadata.
44
+ decryptPart(session.ctx, part.encrypted, part.plain);
45
+ const tables = part.before.map((row) => `"${part.schema}"."${row.table}"`);
46
+ const source = fs.readFileSync(part.plain, 'utf8');
47
+ fs.writeFileSync(part.plain, `begin;\nset constraints all deferred;\n${tables.length ? `truncate ${tables.join(', ')} cascade;\n` : ''}${source}\ncommit;\n`, { mode: 0o600 });
48
+ try { runCli(session, ['db', 'query', '--file', part.plain, '--output', 'json', '--agent', 'no', ...backupArgs(session)]); }
49
+ finally { fs.rmSync(part.plain, { force: true }); }
50
+ if (JSON.stringify(inventory(session, part.schema)) !== JSON.stringify(part.before)) throw failure('DB_RESET_VERIFY_FAILED', `${part.schema} data differs after restore; use the full backup receipt for recovery`);
51
+ }
52
+
53
+ function expectedHead(session) {
54
+ const dir = path.join(session.ctx.cwd, session.database.migrationsDir ?? 'supabase/migrations');
55
+ let expected;
56
+ try { expected = fs.readdirSync(dir).filter((name) => /^\d{14}_[A-Za-z0-9_-]+\.sql$/u.test(name)).map((name) => name.slice(0, 14)).sort(); }
57
+ catch { throw refusal('DB_MIGRATIONS_MISSING', 'migration directory is not readable'); }
58
+ if (!expected.length || new Set(expected).size !== expected.length) throw refusal('DB_MIGRATIONS_MISSING', 'migration versions must be present and unique');
59
+ return expected;
60
+ }
61
+
62
+ function verifyHead(session) {
63
+ const expected = expectedHead(session);
64
+ const actual = queryRows(session, 'select version from supabase_migrations.schema_migrations order by version;').map((row) => row.version);
65
+ if (JSON.stringify(expected) !== JSON.stringify(actual)) throw failure('DB_RESET_HEAD_MISMATCH', 'reset migration history differs from repository migration versions');
66
+ return expected;
67
+ }
68
+
69
+ /** Dry run validates custody and prints a plan; execute preserves chosen managed data, resets, restores and verifies the exact migration head. */
70
+ function resetDatabase(ctx, options = {}) {
71
+ if (options.linked !== true || options.local || options.urlEnv !== undefined) throw refusal('DB_RESET_LINKED_REQUIRED', 'reset requires --linked; use npm run db:reset for a local database');
72
+ if (!['keep', 'drop'].includes(options.auth) || !['keep', 'drop'].includes(options.storage)) throw refusal('DB_RESET_CHOICE_MISSING', 'explicit --auth keep|drop and --storage keep|drop are required');
73
+ const session = backupSession(ctx, options);
74
+ const receipt = validateReceipt(session, options.backup ? path.resolve(ctx.cwd, options.backup) : undefined);
75
+ const lines = [`target: linked project ${session.target.ref}`, `backup: verified encrypted receipt from ${receipt.at}`,
76
+ options.auth === 'keep' ? 'auth: encrypt all auth data tables before reset, restore in a transaction and verify' : 'auth: users and identities are deleted',
77
+ options.storage === 'keep' ? 'storage: preserve and verify buckets and objects metadata' : 'storage: empty storage.objects metadata; files in the storage backend are not deleted by SQL',
78
+ 'reset: supabase db reset --linked; verify migration versions against repository'];
79
+ if (!options.execute) return { ok: true, lines: [...lines, 'dry run: nothing changed'], data: { executed: false, auth: options.auth, storage: options.storage, ref: session.target.ref } };
80
+ requireArmed(ctx);
81
+ // Refuse unsupported CLI file execution before resetting: SQL containing identity data must never travel in argv.
82
+ if (options.auth === 'keep' || options.storage === 'keep') {
83
+ const help = runCli(session, ['db', 'query', '--help']).text;
84
+ if (!help.includes('--file')) throw refusal('DB_RESET_QUERY_FILE_UNSUPPORTED', 'the Supabase CLI must support db query --file for transactional restore');
85
+ }
86
+ expectedHead(session);
87
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'starci-db-reset-'));
88
+ const parts = [];
89
+ let resetStarted = false;
90
+ let complete = false;
91
+ try {
92
+ const keep = ['auth', 'storage'].filter((schema) => options[schema] === 'keep');
93
+ const recipients = keep.length ? backupRecipients(ctx.cwd) : [];
94
+ for (const schema of keep) parts.push(preservePart(session, scratch, schema, recipients));
95
+ resetStarted = true;
96
+ runCli(session, ['db', 'reset', '--yes', ...backupArgs(session)]);
97
+ for (const part of parts) restorePart(session, part);
98
+ if (options.auth === 'drop') {
99
+ const rows = queryRows(session, 'select (select count(*) from auth.users)::text as users, (select count(*) from auth.identities)::text as identities;');
100
+ if (rows.length !== 1 || String(rows[0].users) !== '0' || String(rows[0].identities) !== '0') throw failure('DB_RESET_VERIFY_FAILED', 'auth users or identities remain after reset');
101
+ }
102
+ if (options.storage === 'drop') {
103
+ runCli(session, ['db', 'query', 'begin; delete from storage.objects; commit;', '--output', 'json', '--agent', 'no', ...backupArgs(session)]);
104
+ const rows = queryRows(session, 'select count(*)::text as count from storage.objects;');
105
+ if (rows.length !== 1 || String(rows[0].count) !== '0') throw failure('DB_RESET_VERIFY_FAILED', 'storage.objects metadata was not emptied');
106
+ }
107
+ const versions = verifyHead(session);
108
+ complete = true;
109
+ return { ok: true, lines: [...lines, 'reset completed; managed data choices and migration head verified'], data: { executed: true, auth: options.auth, storage: options.storage, ref: session.target.ref, versions } };
110
+ } finally {
111
+ // Preserve encrypted recovery parts after a reset/restore failure; remove every plaintext file on every exit.
112
+ for (const name of fs.readdirSync(scratch)) if (!name.endsWith('.age')) fs.rmSync(path.join(scratch, name), { force: true });
113
+ if (complete || !resetStarted) fs.rmSync(scratch, { recursive: true, force: true });
114
+ else ctx.stderr(`DB_RESET_RECOVERY_PARTS: encrypted recovery parts retained at ${scratch}\n`);
115
+ }
116
+ }
117
+
118
+ export function dbResetMain(argv, io = {}) {
119
+ return runVerb({ label: 'starci app db reset', name: 'db reset', code: 'DB_USAGE', io, argv,
120
+ spec: { boolean: ['--linked', '--execute', '--json', '--local'], value: ['--auth', '--storage', '--backup', '--url-env'] },
121
+ run: (ctx, flags, positional) => {
122
+ if (positional.length) throw refusal('DB_USAGE', 'reset takes flags only');
123
+ return resetDatabase(ctx, { linked: flags.linked, local: flags.local, urlEnv: flags['url-env'], execute: flags.execute, auth: flags.auth, storage: flags.storage, backup: flags.backup });
124
+ } });
125
+ }