@starci/hfs 4.7.10 → 4.7.12

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 (34) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/db/local-migrate.mjs +19 -6
  3. package/db/migrate.mjs +74 -17
  4. package/package.json +1 -1
  5. package/release/gate.mjs +15 -5
  6. package/runtime/engine/digest.mjs +16 -0
  7. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  8. package/runtime/modules/schemas/application-stacks.schema.yaml +2 -0
  9. package/runtime/packages/cli/src/catalog.generated.mjs +5450 -3964
  10. package/runtime/packages/cli/src/validate-args.mjs +12 -1
  11. package/runtime/scripts/api/fs/is-link-like.mjs +1 -0
  12. package/runtime/scripts/api/sops/lib.mjs +5 -1
  13. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +1 -1
  14. package/runtime/scripts/hfs/architecture/tiers.mjs +1 -2
  15. package/runtime/scripts/hfs/db-plan-verb.mjs +4 -5
  16. package/runtime/scripts/hfs/rule-catalog.mjs +3 -2
  17. package/runtime/scripts/hfs/rules/deploy-scan.mjs +1 -1
  18. package/runtime/scripts/hfs/rules/doc-fresh.mjs +1 -1
  19. package/runtime/scripts/hfs/rules/frontend-tree.mjs +1 -1
  20. package/runtime/scripts/hfs/rules/release-provenance.mjs +1 -1
  21. package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -1
  22. package/runtime/scripts/hfs/rules/stack-tooling.mjs +1 -1
  23. package/runtime/scripts/hfs/rules/stacks-release.mjs +1 -1
  24. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +1 -1
  25. package/runtime/scripts/hfs/rules/supply-chain.mjs +1 -0
  26. package/runtime/scripts/hfs/rules/workflow-contexts.mjs +1 -1
  27. package/runtime/scripts/hfs/secret-bootstrap-crypto.mjs +1 -1
  28. package/runtime/scripts/lib/path-key.mjs +2 -0
  29. package/runtime/scripts/lib/secret-patterns.mjs +1 -1
  30. package/runtime/scripts/lib/ts-analysis.mjs +8 -4
  31. package/stack/cli.mjs +14 -3
  32. package/stack/docker-transport.mjs +3 -1
  33. package/stack/remote.mjs +8 -2
  34. package/stack/target-binding.mjs +125 -0
@@ -120,6 +120,16 @@ const defaultArgs = (locals, values) => {
120
120
  return args;
121
121
  };
122
122
 
123
+ /** Select the catalogued action policy; a missing policy refuses before runtime or provider dispatch. */
124
+ const operationPolicyOf = (verb, positionals) => {
125
+ if (verb.operationPolicy === undefined) return null;
126
+ const policy = verb.operationPolicy?.[positionals[0]];
127
+ if (!policy || typeof policy.action !== 'string' || !Array.isArray(policy.credentials)) {
128
+ throw new Error('the selected action has no executable operation policy');
129
+ }
130
+ return policy;
131
+ };
132
+
123
133
  /**
124
134
  * Validate the arguments after a catalog group and verb.
125
135
  *
@@ -166,7 +176,8 @@ export function validateArgs(argv, verb, globalFlags = []) {
166
176
  requiredCheck(locals, values);
167
177
  checkPositionals(verb, positionals, passthrough);
168
178
  checkGlobals(verb, global);
169
- return { ok: true, values, args: defaultArgs(locals, values), global, localArgs, positionals };
179
+ const operationPolicy = operationPolicyOf(verb, positionals);
180
+ return { ok: true, values, args: defaultArgs(locals, values), global, localArgs, positionals, operationPolicy };
170
181
  } catch (error) {
171
182
  return { ok: false, code: 2, error: String(error?.message ?? error) };
172
183
  }
@@ -15,6 +15,7 @@ import { realpathOr } from '../../lib/fs-kind.mjs';
15
15
  export function isLinkLike(p, { parentReal = null, stat = null } = {}) {
16
16
  let st = stat;
17
17
  if (!st) { try { st = fs.lstatSync(p); } catch { return false; } }
18
+ if (!st) return false;
18
19
  if (st.isSymbolicLink()) return true;
19
20
  if (!st.isDirectory()) return false;
20
21
  try { fs.readlinkSync(p); return true; } catch { /* not a link readlink can read */ }
@@ -104,7 +104,11 @@ export const SELECTED_AGE_VERSIONS = new Set(['1.2.1', '1.3.1']);
104
104
  const IDENTITY_TIMEOUT_MS = 20_000;
105
105
  const CONTROLLED_ENV = new Set(['SOPS_AGE_KEY', 'SOPS_AGE_KEY_FILE', 'SOPS_AGE_KEY_CMD', 'SOPS_AGE_SSH_PRIVATE_KEY_FILE', 'SOPS_AGE_SSH_PRIVATE_KEY_CMD', 'SOPS_AGE_RECIPIENT', 'SOPS_KEYSERVICE', 'SOPS_ENABLE_LOCAL_KEYSERVICE', 'SOPS_DECRYPTION_ORDER', 'SOPS_KMS_ARN', 'SOPS_GCP_KMS_IDS', 'SOPS_HUAWEICLOUD_KMS_IDS', 'SOPS_AZURE_KEYVAULT_URLS', 'SOPS_VAULT_URIS', 'SOPS_PGP_FP', 'SOPS_AGE_RECIPIENTS', 'HOME', 'USERPROFILE', 'APPDATA', 'XDG_CONFIG_HOME']);
106
106
 
107
- /** Construct only the child map; empty desktop roots make Go return directory errors, not fallback paths. */
107
+ /**
108
+ * Construct only the child map; empty desktop roots make Go return directory errors, not fallback paths.
109
+ * @param {object} env The admitted caller environment.
110
+ * @param {string|null} inlineName Selected inline identity name, or null to omit every identity.
111
+ */
108
112
  function isolatedSopsEnv(env, inlineName = 'SOPS_AGE_KEY') {
109
113
  const out = Object.fromEntries(Object.entries(env).filter(([name]) => !CONTROLLED_ENV.has(name.toUpperCase())));
110
114
  out.HOME = ''; out.USERPROFILE = ''; out.APPDATA = ''; out.XDG_CONFIG_HOME = '';
@@ -65,7 +65,7 @@ function templatePatternOf(node) {
65
65
  return [node.head.text, ...node.templateSpans.map(span => span.literal.text)].join('\u0000').split('.').map(segment => {
66
66
  if (!segment.includes('\u0000')) return segment;
67
67
  const parts = segment.split('\u0000');
68
- return parts.every(part => part === '') ? '*' : new RegExp(`^${parts.map(escapeRegExp).join('.*')}$`, 'su');
68
+ return parts.every(part => part === '') ? '*' : new RegExp(`^${parts.map((part) => escapeRegExp(part)).join('.*')}$`, 'su');
69
69
  });
70
70
  }
71
71
 
@@ -72,8 +72,7 @@ function stronglyConnected(adjacency) {
72
72
  for (const start of adjacency.keys()) {
73
73
  if (state.index.has(start)) continue;
74
74
  startTraversal(state, adjacency, start);
75
- while (state.work.length) {
76
- const frame = state.work.at(-1);
75
+ for (let frame = state.work.at(-1); frame; frame = state.work.at(-1)) {
77
76
  const next = frame.iterator.next();
78
77
  if (next.done) {
79
78
  finishFrame(state, frame);
@@ -1,7 +1,7 @@
1
1
  // app db plan: compare migration-owned state without fetching commands or Vault values.
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { createHash } from 'node:crypto';
4
+ import { sha256 } from '../../engine/digest.mjs';
5
5
  import pg from 'libpg-query';
6
6
  import { parse as parseToml } from 'smol-toml';
7
7
  import { invocationDir } from '../lib/roots.mjs';
@@ -19,12 +19,11 @@ const PRESENCE_SQL = `select
19
19
  pg_catalog.to_regclass('vault.secrets') is not null as vault;`;
20
20
  const INVENTORIES = {
21
21
  migrations: 'select version from supabase_migrations.schema_migrations order by version;',
22
- cron: 'select jobname as name, schedule, pg_catalog.md5(command) as "commandHash", active from cron.job order by jobid;',
22
+ cron: 'select jobname as name, schedule, pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(command, \'UTF8\')), \'hex\') as "commandHash", active from cron.job order by jobid;',
23
23
  queues: 'select queue_name as name from pgmq.meta order by queue_name;',
24
24
  vault: 'select name from vault.secrets order by name;',
25
25
  };
26
26
  const names = (nodes) => sqlStringParts(nodes).join('.');
27
- const hash = (command) => createHash('md5').update(command).digest('hex');
28
27
  const OWNERSHIP_CALLS = new Set(['pgmq.create', 'pgmq.drop_queue', 'cron.schedule', 'cron.unschedule', 'cron.schedule_in_database', 'cron.alter_job']);
29
28
 
30
29
  const parseSql = sqlParser(pg, () => { throw new DbVerbError('migration SQL could not be inventoried safely'); });
@@ -45,7 +44,7 @@ function inventoryCall(state, call) {
45
44
  const job = requiredLiteral(call, 0, ['job_name']);
46
45
  const schedule = requiredLiteral(call, 1, ['schedule']);
47
46
  const command = requiredLiteral(call, 2, ['command']);
48
- state.cron.set(job, { schedule, commandHash: hash(command), active: true });
47
+ state.cron.set(job, { schedule, commandHash: sha256(command), active: true });
49
48
  } else if (name === 'cron.unschedule') {
50
49
  state.cron.delete(requiredLiteral(call, 0, ['job_name']));
51
50
  } else if (name === 'cron.schedule_in_database' || name === 'cron.alter_job') {
@@ -224,7 +223,7 @@ function compare(expected, actual, kind) {
224
223
  for (const row of actual) {
225
224
  const key = kind === 'migrations' ? row.version : row.name;
226
225
  if (key !== null && (typeof key !== 'string' || key.length === 0)) throw new DbVerbError('Supabase returned an invalid inventory identifier');
227
- if (kind === 'cron' && (typeof row.schedule !== 'string' || !/^[a-f0-9]{32}$/.test(row.commandHash ?? '') || typeof row.active !== 'boolean')) throw new DbVerbError('Supabase returned invalid cron metadata');
226
+ if (kind === 'cron' && (typeof row.schedule !== 'string' || !/^[a-f0-9]{64}$/.test(row.commandHash ?? '') || typeof row.active !== 'boolean')) throw new DbVerbError('Supabase returned invalid cron metadata');
228
227
  if (!unmatched.delete(key)) unexpected += 1;
229
228
  else if (kind === 'cron') {
230
229
  const desired = expected.get(key);
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { skillRoot } from '../../engine/runtime-root.mjs';
5
5
  import { parseYamlCached } from '../lib/yaml-cached.mjs';
6
6
  import { jsonClone } from '../lib/json-clone.mjs';
7
+ import { byCodeUnit } from '../lib/list.mjs';
7
8
  import { isPlainObject } from '../../engine/plain-object.mjs';
8
9
  import { SEMVER } from './manifest-shape.mjs';
9
10
  import { enforcerJudgedInEdition, judgedInEdition, ruleEditionProblems } from './edition-slots.mjs';
@@ -39,7 +40,7 @@ function relativePathProblem(value, label, bad, anchor = false) {
39
40
  const parts = typeof value === 'string' ? value.split('#') : [];
40
41
  const file = parts[0];
41
42
  const valid = typeof value === 'string' && value.trim() === value && value.length > 0
42
- && !/[\r\n\u0000\\]/.test(value) && !/^(?:\/|[a-z][a-z0-9+.-]*:)/i.test(value) && !file.includes(':')
43
+ && !value.includes('\0') && !/[\r\n\\]/.test(value) && !/^(?:\/|[a-z][a-z0-9+.-]*:)/i.test(value) && !file.includes(':')
43
44
  && !file.split('/').some((part) => ['', '.', '..'].includes(part))
44
45
  && (parts.length === 1 || (anchor && parts.length === 2 && /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/.test(parts[1])));
45
46
  if (!valid) bad.push(`${label} must be a repository-relative path${anchor ? ' with an optional #anchor' : ''}`);
@@ -51,7 +52,7 @@ function unknownKeys(item, allowed, label, bad) {
51
52
 
52
53
  function nonEmptyList(value, label, bad) {
53
54
  if (!Array.isArray(value) || !value.length) { bad.push(`${label} must be a non-empty list`); return false; }
54
- const normalized = value.map((value) => JSON.stringify(value, (_key, item) => isPlainObject(item) ? Object.fromEntries(Object.keys(item).sort().map((key) => [key, item[key]])) : item));
55
+ const normalized = value.map((value) => JSON.stringify(value, (_key, item) => isPlainObject(item) ? Object.fromEntries(Object.keys(item).sort(byCodeUnit).map((key) => [key, item[key]])) : item));
55
56
  if (new Set(normalized).size !== value.length) bad.push(`${label} repeats a value`);
56
57
  return true;
57
58
  }
@@ -8,7 +8,7 @@ const DEPLOY = /(?:^|\/)deploy\//;
8
8
  const SCRIPT = /(?:^|\/)scripts\//;
9
9
  const COMPOSE = /(?:^|\/)(?:docker-)?compose(?:[.-][^/]*)?\.ya?ml$/;
10
10
  const DOCKERFILE = /(?:^|\/)Dockerfile(?:\.[^/]*)?$/;
11
- const CREDENTIAL_FILE = /(?:^|\/)(?:secrets?\.env(?:\.[^/]*)?|\.env(?:\.[^/]*)?|secrets\/)|\.enc$/i;
11
+ const CREDENTIAL_FILE = /(?:(?:^|\/)(?:secrets?\.env(?:\.[^/]*)?|\.env(?:\.[^/]*)?|secrets\/))|(?:\.enc$)/i;
12
12
  const HOST_CRON = /\b(?:crontab|anacron|crond|supercronic)\b|\/etc\/cron(?:\b|[./])|\bcron\.d\b|\/var\/spool\/cron\b|@(?:reboot|yearly|annually|monthly|weekly|daily|midnight|hourly)\b|\b[\w.@-]+\.timer\b|\bOn(?:Calendar|BootSec|UnitActiveSec|ActiveSec)\s*=|(?:^|[\s;|&"'])at\s+(?:(?:-[a-z]+\s+)*)((?:-[ft]\b)|now\b|tomorrow\b|\d{1,2}:\d{2}\b|midnight\b|noon\b)/im;
13
13
  const HOST_CRON_PATH = /(?:^|\/)(?:crontab(?:$|\/)|cron\.d(?:\/|$)|[^/]+\.(?:timer|crontab)$)/;
14
14
  const CRON_NUMERIC_FIELD = /^[\d*,/-]+$/;
@@ -228,7 +228,7 @@ function injectionTokens(repoRoot, tracked) {
228
228
  const ts = loadTypescript(repoRoot, path.dirname(fileURLToPath(import.meta.url)));
229
229
  if (!ts) return tokens;
230
230
  for (const file of tracked) {
231
- if (!/\.[cm]?[jt]sx?$/.test(file) || /(?:^|\/)(?:tests?|__tests__)\/|(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$/.test(file)) continue;
231
+ if (!/\.[cm]?[jt]sx?$/.test(file) || /(?:(?:^|\/)(?:tests?|__tests__)\/)|(?:(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$)/.test(file)) continue;
232
232
  const text = readText(repoRoot, file) ?? '';
233
233
  declaredTokens(ts, ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true), tokens);
234
234
  }
@@ -37,7 +37,7 @@ function wireSchemaDigest(repoRoot, snapshots) {
37
37
  for (const snapshot of [...snapshots].sort(byCodeUnit)) {
38
38
  let source;
39
39
  try { source = fs.readFileSync(path.join(repoRoot, snapshot), 'utf8'); } catch { return null; }
40
- hash.update(`${snapshot}\0${source.replace(/\r\n/g, '\n')}\0`);
40
+ hash.update(`${snapshot}\0${source.replaceAll('\r\n', '\n')}\0`);
41
41
  }
42
42
  return hash.digest('hex');
43
43
  }
@@ -79,7 +79,7 @@ function refProof(value) {
79
79
  if (typeof value !== 'string') return 0;
80
80
  const expression = unwrap(workflowExpression(value));
81
81
  const alternatives = splitBoolean(expression, '||');
82
- if (alternatives.length > 1) return alternatives.map(refProof).reduce((a, b) => a & b);
82
+ if (alternatives.length > 1) return alternatives.map(refProof).reduce((a, b) => a & b, RELEASE_REF);
83
83
  const conjuncts = splitBoolean(expression, '&&');
84
84
  if (conjuncts.length > 1) return conjuncts.map(refProof).reduce((a, b) => a | b, 0);
85
85
  if (/^startsWith\(\s*github\.ref\s*,\s*(['"])refs\/tags\/v\1\s*\)$/.test(expression)) return RELEASE_REF;
@@ -7,7 +7,7 @@ const PLAINTEXT_LOAD = 'HFS_SCRIPT_PLAINTEXT_LOAD';
7
7
  const XTRACE = 'HFS_SCRIPT_XTRACE';
8
8
  const SERVICE_ROLE = 'HFS_SERVICE_ROLE_SCOPE';
9
9
  const SUPABASE_OWNER = 'be.integrations.supabase';
10
- const SECRET_FILE = /(?:^|\/)(?:secrets?\.env(?:\.[^/]*)?|\.env(?:\.[^/]*)?|\.nivo-lite(?:\/|$))|\.enc$/iu;
10
+ const SECRET_FILE = /(?:(?:^|\/)(?:secrets?\.env(?:\.[^/]*)?|\.env(?:\.[^/]*)?|\.nivo-lite(?:\/|$)))|(?:\.enc$)/iu;
11
11
  const EXCLUDED_TREE = /(?:^|\/)(?:node_modules|\.git|\.claude|\.secrets|secrets|tests?|__tests__|docs|coverage|dist|build)(?:\/|$)/u;
12
12
  const TEST_FILE = /(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$/u;
13
13
  const SOURCE = /\.(?:[cm]?[jt]sx?|sh|bash|zsh|ksh|ps1|psm1|bat|cmd|py|rb|pl)$/iu;
@@ -13,7 +13,7 @@ const SCRIPTS_DIR = /(?:^|\/)scripts\//;
13
13
  const PACKAGE_JSON = /(?:^|\/)package\.json$/;
14
14
  const SOURCE_FILE = /(?:^|\/)[^/.]+$|\.(?:sh|bash|zsh|mjs|cjs|js|ts|mts|py|ya?ml)$/;
15
15
  const TEST_FILE = /\.(?:spec|test)\.[cm]?[jt]s$/;
16
- const CREDENTIAL_FILE = /(?:^|\/)(?:secrets?\.env(?:\.[^/]*)?|\.env(?:\.[^/]*)?|secrets\/)|\.enc$/i;
16
+ const CREDENTIAL_FILE = /(?:(?:^|\/)(?:secrets?\.env(?:\.[^/]*)?|\.env(?:\.[^/]*)?|secrets\/))|(?:\.enc$)/i;
17
17
  const VERB_FILE = /^(render|provision|converge|verify|prune|rollback|release|backup|prepare|doctor|jobs|public)\.(?:mjs|js|cjs|sh)$/;
18
18
  const COMMENT_LINE = /^[ \t]*(?:#|\/\/).*$/gm;
19
19
  const LINE_CONTINUATION = /\\\r?\n\s*/g;
@@ -26,7 +26,7 @@ const DECLARATION = '.starcistacks/application-stacks.yaml';
26
26
  const MAX_BYTES = 1024 * 1024;
27
27
  const SOURCE = /^(?:be\/)?(?:src|apps)\/.*\.[cm]?[jt]s$/;
28
28
  const TEST = /(?:\.(?:spec|test)|-spec)\.[cm]?[jt]s$|\/(?:tests|__tests__)\//;
29
- const SECRET = /(?:^|\/)(?:secrets|\.claude|\.aws|\.codex)(?:\/|$)|(?:^|\/)\.env(?:\.|$)|\.(?:enc|pem|key|p12|pfx)$/i;
29
+ const SECRET = /(?:(?:^|\/)(?:secrets|\.claude|\.aws|\.codex)(?:\/|$))|(?:(?:^|\/)\.env(?:\.|$))|(?:\.(?:enc|pem|key|p12|pfx)$)/i;
30
30
  const CLI_ENTRY = /^(?:be\/)?dist\/apps\/cli\/[A-Za-z0-9_./-]+\.(?:js|mjs|cjs)$/;
31
31
  const mapping = value => plain(value) ? value : {};
32
32
  const present = value => typeof value === 'string' && Boolean(value.trim());
@@ -16,7 +16,7 @@ const mapping = value => plain(value) ? value : {};
16
16
  const present = value => typeof value === 'string' && Boolean(value.trim());
17
17
  const VERSION = /^[1-9][0-9]*$/;
18
18
  const VERSIONED_SECRET = /-v[1-9][0-9]*$/;
19
- const SENSITIVE_KEY = /password|passwd|secret|token|credential|private[_-]?key|api[_-]?key|service[_-]?role|(?:^|_)key$/i;
19
+ const SENSITIVE_KEY = /password|passwd|secret|token|credential|private[_-]?key|api[_-]?key|service[_-]?role|(?:(?:^|_)key$)/i;
20
20
  const SOURCE = /^be\/(?:apps|src)\/.*\.[cm]?[jt]sx?$/;
21
21
  const TEST_SOURCE = /(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$|\/tests\//;
22
22
  const REALTIME_SOURCE = /^be\/src\/features\/realtime\/[^/]+\//;
@@ -32,6 +32,7 @@ function appendShellCommand(commands, state, next) {
32
32
  }
33
33
 
34
34
  function shellLineCommands(line, commands) {
35
+ /** @type {{quote: string | null, command: string, separator: string | null}} */
35
36
  const state = { quote: null, command: '', separator: null };
36
37
  for (let i = 0; i < line.length; i++) {
37
38
  const char = line[i];
@@ -76,7 +76,7 @@ function expressions(value) {
76
76
 
77
77
  /** Source markers travel inside parsed scalar expressions, preserving flow maps, quoted keys and repeated values without matching comments. */
78
78
  export function workflowContextFindings({ path: file, text }) {
79
- const marked = text.replace(/\$\{\{/g, (match, offset) => `${match} __hfs_context_line_${text.slice(0, offset).split('\n').length}__ `);
79
+ const marked = text.replaceAll('${{', (match, offset) => `${match} __hfs_context_line_${text.slice(0, offset).split('\n').length}__ `);
80
80
  const findings = [];
81
81
  function inspect(value, keys) {
82
82
  const allowed = allowedContexts(keys);
@@ -22,7 +22,7 @@ function checksumStep(checksum, value) {
22
22
  function checksumWords(prefix, words) {
23
23
  const codes = [...prefix].map((character) => character.codePointAt(0));
24
24
  const expanded = [...codes.map((code) => code >>> 5), 0, ...codes.map((code) => code & 31)];
25
- return [...expanded, ...words].reduce(checksumStep, 1);
25
+ return [...expanded, ...words].reduce((checksum, value) => checksumStep(checksum, value), 1);
26
26
  }
27
27
 
28
28
  function convertBits(input, from, to, pad) {
@@ -40,6 +40,8 @@ export const pathsOverlap = (a, b) => sameOrUnder(a, b) || sameOrUnder(b, a);
40
40
  * `trim` blanks at the ends, `collapse` folds '//' runs to '/', `dot` drops a leading './',
41
41
  * `glob` strips a trailing glob ('star': '*'/'**' with their slash, 'double': a '/**' only),
42
42
  * `fold` lowercases. A trailing '/' always goes. Null reads as ''.
43
+ * @param {unknown} p
44
+ * @param {{trim?: boolean, collapse?: boolean, dot?: boolean, glob?: string|null, fold?: boolean}} [options]
43
45
  */
44
46
  export const normPath = (p, { trim = false, collapse = false, dot = false, glob = null, fold = false } = {}) => {
45
47
  let s = String(p ?? '');
@@ -173,7 +173,7 @@ function readmeBadgeText(text, manifestText) {
173
173
 
174
174
  /** Whole-file secret judgement exempts only declared public badge values and preserves original line numbers and every provider shape. */
175
175
  export function secretHitsInFile(file, fullText, { manifestText } = {}) {
176
- const normalized = file.replace(/\\/g, '/');
176
+ const normalized = file.replaceAll('\\', '/');
177
177
  let text = fullText;
178
178
  if (/(?:^|\/)\.starcistacks\/application-stacks\.yaml$/.test(normalized)) text = manifestBadgeText(text);
179
179
  if (/(?:^|\/)README\.md$/.test(normalized)) text = readmeBadgeText(text, manifestText);
@@ -1,9 +1,13 @@
1
1
  /** The expression under the wrappers selected by an AST check's contract. */
2
2
  export function unwrapExpression(ts, expression, { awaitExpressions = false, typeAssertions = true, nonNullExpressions = true, satisfiesExpressions = true } = {}) {
3
- while (expression && (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression)
4
- || (typeAssertions && ts.isTypeAssertionExpression(expression)) || (nonNullExpressions && ts.isNonNullExpression(expression))
5
- || (satisfiesExpressions && (ts.isSatisfiesExpression?.(expression) ?? false))
6
- || (awaitExpressions && ts.isAwaitExpression(expression)))) expression = expression.expression;
3
+ while (expression) {
4
+ const wrapper = ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression)
5
+ || (typeAssertions && ts.isTypeAssertionExpression(expression)) || (nonNullExpressions && ts.isNonNullExpression(expression))
6
+ || (satisfiesExpressions && (ts.isSatisfiesExpression?.(expression) ?? false))
7
+ || (awaitExpressions && ts.isAwaitExpression(expression));
8
+ if (!wrapper) break;
9
+ expression = expression.expression;
10
+ }
7
11
  return expression;
8
12
  }
9
13
 
package/stack/cli.mjs CHANGED
@@ -7,6 +7,7 @@ import { stackContext } from './context.mjs';
7
7
  import { loadDeployConfig } from './config.mjs';
8
8
  import { withDockerTransport } from './docker-transport.mjs';
9
9
  import { StackError, EXIT, refusal } from './errors.mjs';
10
+ import { parseTargetBinding, bindStackTarget, nativeToolEnvironment } from './target-binding.mjs';
10
11
 
11
12
  /** The actions that drive the docker CLI of the operator machine (pointed at the manager by docker-transport.mjs when the key is sealed). */
12
13
  const DOCKER_ACTIONS = new Set(['deploy', 'accept', 'converge', 'verify', 'rollback', 'prune']);
@@ -14,7 +15,7 @@ const DOCKER_ACTIONS = new Set(['deploy', 'accept', 'converge', 'verify', 'rollb
14
15
  export const ACTIONS = Object.freeze(['prepare', 'doctor', 'connect', 'disconnect', 'build-args', 'render', 'deploy', 'converge', 'verify', 'rollback', 'prune', 'backup', 'jobs', 'task', 'ingress', 'retire', 'build', 'accept']);
15
16
 
16
17
  const BOOLEAN = new Set(['--execute', '--json', '--swarm-state', '--init-swarm', '--owner-approved', '--fix-live-restore', '--skip-public', '--skip-internal', '--allow-unpinned', '--push']);
17
- const VALUE = new Set(['--env', '--image', '--source-sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project']);
18
+ const VALUE = new Set(['--env', '--image', '--source-sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project', '--target-binding']);
18
19
  const REPEAT = new Set(['--image', '--keep', '--extra-compose', '--project']);
19
20
 
20
21
  /** Splits argv into { flags, positional }; an unknown flag or a flag without its value is a refusal. */
@@ -107,6 +108,12 @@ function print(ctx, action, flags, result) {
107
108
  else for (const line of result.lines ?? []) ctx.stdout(`${line}\n`);
108
109
  }
109
110
 
111
+ function nativeToolContext(ctx, binding) {
112
+ if (!binding) return ctx;
113
+ return Object.create(ctx, { spawn: { value: (command, args, options = {}) => ctx.spawn(command, args,
114
+ { ...options, env: nativeToolEnvironment(options.env ?? ctx.env) }) } });
115
+ }
116
+
110
117
  /** Runs one action; returns the exit code. `io.importer` lets a spec load fake action modules. */
111
118
  export async function stackMain(argv, io = {}) {
112
119
  const ctx = stackContext(io);
@@ -114,12 +121,16 @@ export async function stackMain(argv, io = {}) {
114
121
  const { flags, positional } = parseFlags(argv.slice(1));
115
122
  const action = argv[0];
116
123
  if (!ACTIONS.includes(action)) throw refusal('STACK_USAGE', `starci app stack takes ${[...ACTIONS, 'up', 'down', 'status'].join(', ')}`);
117
- const config = loadDeployConfig({ repoRoot: ctx.cwd, env: flags.env ?? 'production' });
124
+ const binding = parseTargetBinding(flags['target-binding'], 'docker-swarm');
125
+ if (binding && (!['deploy', 'verify'].includes(action) || flags['ssh-cmd'] || flags['skip-public'] || flags['skip-internal']))
126
+ throw refusal('STACK_USAGE', 'the bound native consumer supports only complete deploy or verify');
127
+ const config = bindStackTarget(ctx, loadDeployConfig({ repoRoot: ctx.cwd, env: flags.env ?? 'production' }), binding);
118
128
  const options = OPTIONS[action]({ flags, positional, cwd: ctx.cwd });
119
129
  const handler = await handlerOf(action, io.importer ?? ((file) => import(file)));
120
130
  const sshCmd = flags['ssh-cmd'] ?? null;
121
131
  const call = (context) => handler(context, config, { ...options, sshCmd });
122
- const result = DOCKER_ACTIONS.has(action) ? await withDockerTransport(ctx, config, { sshCmd }, call) : await call(ctx);
132
+ const toolContext = nativeToolContext(ctx, binding);
133
+ const result = DOCKER_ACTIONS.has(action) ? await withDockerTransport(toolContext, config, { sshCmd }, call) : await call(toolContext);
123
134
  print(ctx, action, flags, result);
124
135
  return result.ok ? EXIT.ok : EXIT.failed;
125
136
  } catch (error) {
@@ -17,6 +17,7 @@ const controlLines = (controlPath) => controlSettings(configPath(controlPath)).m
17
17
 
18
18
  /** True when the operator's docker CLI has to be pointed at the manager by this module. */
19
19
  function needed(ctx, config, sshCmd) {
20
+ if (config.nativeTarget) return true;
20
21
  if (sshCmd || ctx.env.DOCKER_HOST || ctx.env.GITHUB_ACTIONS === 'true' || ctx.env.STARCI_STACK_SEALED_SSH === '0') return false;
21
22
  const { sealed } = config.remote;
22
23
  if (!sealed) return false;
@@ -27,6 +28,7 @@ function sshConfig(session, controlPath, windows) {
27
28
  const at = session.account.lastIndexOf('@');
28
29
  return [
29
30
  `Host ${ALIAS}`, ` HostName ${session.account.slice(at + 1)}`, ` User ${session.account.slice(0, at)}`,
31
+ ...(session.port ? [` Port ${session.port}`] : []),
30
32
  ` IdentityFile ${configPath(session.keyFile)}`, ' IdentitiesOnly yes', ` UserKnownHostsFile ${configPath(session.knownFile)}`,
31
33
  ` StrictHostKeyChecking ${session.strict}`, ' BatchMode yes', ' ConnectTimeout 20',
32
34
  ...(windows ? [] : controlLines(controlPath)), '',
@@ -102,7 +104,7 @@ export async function withDockerTransport(ctx, config, { sshCmd = null } = {}, b
102
104
  const baseEnv = session ? dockerEnvironment(ctx, session, controlPath) : ctx.env;
103
105
  const { env, executable } = privateSshEnvironment(ctx, baseEnv, dir, controlPath);
104
106
  const configFile = path.join(env.HOME ?? '', '.ssh', 'config');
105
- const options = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=20'];
107
+ const options = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=20', ...(session?.port ? ['-p', String(session.port)] : [])];
106
108
  const argv = session || executable !== 'ssh' ? [executable, ...options, '-F', configFile, '--', alias] : ['ssh', ...options, '--', alias];
107
109
  connection = createSshSession(ctx, { argv, env, controlPath, cleanup });
108
110
  const spawn = dockerSpawn(ctx, connection, env, alias);
package/stack/remote.mjs CHANGED
@@ -14,6 +14,7 @@ import { tool } from './context.mjs';
14
14
  import { restrictToUser, writePrivate } from './private-file.mjs';
15
15
  import { refusal } from './errors.mjs';
16
16
  import { createSshSession, sessionDirectory } from './ssh-session.mjs';
17
+ import { assertStackAccount } from './target-binding.mjs';
17
18
 
18
19
  const ACCOUNT = /^[A-Za-z0-9._@-]+$/u;
19
20
  const CI_VARIABLES = ['VPS_SSH_KEY', 'VPS_KNOWN_HOSTS', 'VPS_HOST', 'VPS_USER'];
@@ -92,9 +93,12 @@ function writeWithMode(file, text, mode) {
92
93
  export function openSealedSession(ctx, config) {
93
94
  const { sealed } = config.remote;
94
95
  const account = opsAccount(ctx, config);
96
+ if (config.nativeTarget && account !== `${config.nativeTarget.user}@${config.nativeTarget.host}`)
97
+ throw refusal('STACK_REMOTE_OPS', 'the sealed SSH account differs from the accepted native target');
95
98
  const key = binaryMember(ctx, config, sealed.sshKey, ['privateKey', 'key']);
96
99
  if (!key.includes('PRIVATE KEY')) throw refusal('STACK_REMOTE_KEY', `${sealed.sshKey} does not hold a PEM private key`);
97
100
  const known = binaryMember(ctx, config, sealed.knownHosts, ['knownHosts', 'known_hosts']).trim();
101
+ assertStackAccount(config.nativeTarget, account, known);
98
102
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stack-ssh-'));
99
103
  const keyFile = path.join(dir, 'id');
100
104
  const knownFile = path.join(dir, 'known_hosts');
@@ -113,13 +117,14 @@ export function openSealedSession(ctx, config) {
113
117
  throw error;
114
118
  }
115
119
  const notes = known ? [] : ['owner-machine session without known_hosts: the host key is accepted on first contact (accept-new); verify it, CI never does this'];
116
- return { account, dir, keyFile, knownFile, strict: known ? 'yes' : 'accept-new', notes, cleanup };
120
+ return { account, dir, keyFile, knownFile, strict: known ? 'yes' : 'accept-new', notes, cleanup,
121
+ ...(config.nativeTarget ? { port: config.nativeTarget.port } : {}) };
117
122
  }
118
123
 
119
124
  /** The owner-machine runner over that session. */
120
125
  function sealedRunner(ctx, config) {
121
126
  const session = openSealedSession(ctx, config);
122
- const argv = ['ssh', '-T', '-i', session.keyFile, '-o', 'IdentitiesOnly=yes', '-o', `UserKnownHostsFile=${session.knownFile}`, '-o', `StrictHostKeyChecking=${session.strict}`, '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=20', session.account];
127
+ const argv = ['ssh', '-T', ...(session.port ? ['-p', String(session.port)] : []), '-i', session.keyFile, '-o', 'IdentitiesOnly=yes', '-o', `UserKnownHostsFile=${session.knownFile}`, '-o', `StrictHostKeyChecking=${session.strict}`, '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=20', session.account];
123
128
  return sessionRunner(ctx, argv, session.dir, session.cleanup, session.notes);
124
129
  }
125
130
 
@@ -143,6 +148,7 @@ function sshRunner(ctx, config, { sshCmd = null } = {}) {
143
148
  */
144
149
  function withOwnerTransport(ctx, config, { sshCmd = null } = {}) {
145
150
  const { sealed } = config.remote;
151
+ if (config.nativeTarget) return Object.create(ctx, { env: { value: { ...ctx.env, STARCI_STACK_SEALED_SSH: '1' }, enumerable: true } });
146
152
  if (sshCmd || !sealed || ctx.env.GITHUB_ACTIONS === 'true' || ctx.env.STARCI_STACK_SEALED_SSH !== undefined) return ctx;
147
153
  const present = [sealed.ops, sealed.sshKey, sealed.knownHosts].every((slug) => fs.existsSync(path.join(config.secretsDir, `${slug}.enc`)));
148
154
  if (!present) return ctx;
@@ -0,0 +1,125 @@
1
+ // Public endpoint assertions for admitted native consumers; this module never opens custody.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { refusal } from './errors.mjs';
5
+
6
+ const slug = value => typeof value === 'string' && /^[a-z][a-z0-9-]*$/.test(value);
7
+ const label = value => typeof value === 'string' && /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(value);
8
+ const host = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(value);
9
+ const port = value => Number.isInteger(value) && value > 0 && value <= 65535;
10
+ const denied = () => { throw refusal('STACKS_DEPLOY_CONFIG_INVALID', 'the native endpoint binding is invalid or differs from the selected owner target'); };
11
+ const exact = (value, fields) => value && typeof value === 'object' && !Array.isArray(value)
12
+ && Object.keys(value).length === fields.length && fields.every(key => Object.hasOwn(value, key));
13
+
14
+ function ordinaryInput(root, file) {
15
+ if (typeof root !== 'string' || !path.isAbsolute(root)) denied();
16
+ const relative = path.relative(root, file);
17
+ if (!relative || path.isAbsolute(relative) || relative.split(path.sep).includes('..')) denied();
18
+ let current = root;
19
+ const parts = relative.split(path.sep);
20
+ for (let index = 0; index <= parts.length; index += 1) {
21
+ let stat;
22
+ try { stat = fs.lstatSync(current); } catch { denied(); }
23
+ if (stat.isSymbolicLink() || (index === parts.length ? !stat.isFile() || stat.nlink !== 1 : !stat.isDirectory())) denied();
24
+ if (index < parts.length) current = path.join(current, parts[index]);
25
+ }
26
+ }
27
+
28
+ /** A bound native owner opens only an ordinary envelope below its actual app, without ancestor links. */
29
+ export const assertBoundCustody = (root, file) => ordinaryInput(root, file);
30
+
31
+ /** Bound database commands require the app-local launcher; they cannot discover a global replacement. */
32
+ export function boundSupabaseLauncher(root) {
33
+ const launcher = path.join(root, 'node_modules', 'supabase', 'dist', 'supabase.js');
34
+ ordinaryInput(root, launcher);
35
+ return launcher;
36
+ }
37
+
38
+ /** Native custody can use AGE; tools that do not decrypt custody must not inherit that identity. */
39
+ export function nativeToolEnvironment(env) {
40
+ return Object.fromEntries(Object.entries(env).filter(([key]) => !/^SOPS_/i.test(key)));
41
+ }
42
+
43
+ /** Bound database tools receive only their selected private password and native routing, without ambient libpq or provider credentials. */
44
+ export function nativeDatabaseEnvironment(env, binding, selected = {}, omit = []) {
45
+ const omitted = new Set(omit.filter(value => typeof value === 'string').map(value => value.toUpperCase()));
46
+ return { ...Object.fromEntries(Object.entries(nativeToolEnvironment(env)).filter(([key]) => !/^(?:PG[A-Z_]*|SUPABASE_)/i.test(key) && !omitted.has(key.toUpperCase()))),
47
+ 'SUPABASE_ENV': binding.environment, ...selected };
48
+ }
49
+
50
+ /** Preserve a logical linked project while selecting a fixed native SQL endpoint without the CLI's linked Management finalizer. */
51
+ export function boundLinkedDatabaseUrl(binding, savedPooler = null) {
52
+ if (!binding || binding.mode !== 'linked' || !/^[a-z0-9]{20}$/.test(binding.projectRef)) denied();
53
+ if (savedPooler === null) return `postgresql://postgres@db.${binding.projectRef}.supabase.co:5432/postgres?sslmode=require`;
54
+ let url;
55
+ try {
56
+ if (typeof savedPooler !== 'string' || savedPooler.length > 8192) denied();
57
+ url = new URL(savedPooler.trim());
58
+ if (!['postgres:', 'postgresql:'].includes(url.protocol) || !/^[A-Za-z0-9.-]+\.pooler\.supabase\.com$/.test(url.hostname)
59
+ || decodeURIComponent(url.username) !== `postgres.${binding.projectRef}` || url.pathname !== '/postgres'
60
+ || url.port && !['5432', '6543'].includes(url.port) || url.hash
61
+ || [...url.searchParams].some(([key, value]) => key !== 'sslmode' || !['require', 'verify-full'].includes(value))) denied();
62
+ } catch { denied(); }
63
+ url.password = '';
64
+ url.port = '5432';
65
+ if (!url.searchParams.has('sslmode')) url.searchParams.set('sslmode', 'require');
66
+ return url.toString();
67
+ }
68
+
69
+ /** Parse the internal public binding; ordinary commands without this flag retain their native behavior. */
70
+ export function parseTargetBinding(text, kind) {
71
+ if (text === undefined) return null;
72
+ let binding;
73
+ try { if (typeof text !== 'string' || text.length > 4096) denied(); binding = JSON.parse(text); } catch { denied(); }
74
+ if (binding?.kind !== kind || !slug(binding.environment)) denied();
75
+ if (kind === 'docker-swarm') {
76
+ if (!exact(binding, ['kind', 'environment', 'stack', 'host', 'user', 'port']) || !slug(binding.stack)
77
+ || !host(binding.host) || !label(binding.user) || binding.port !== 22) denied();
78
+ } else if (kind === 'supabase-db') {
79
+ if (binding.mode === 'local') {
80
+ if (!exact(binding, ['kind', 'mode', 'environment', 'localProject', 'port']) || !label(binding.localProject) || !port(binding.port)) denied();
81
+ } else if (binding.mode === 'linked') {
82
+ if (!exact(binding, ['kind', 'mode', 'environment', 'projectRef']) || !/^[a-z0-9]{20}$/.test(binding.projectRef ?? '')) denied();
83
+ } else if (binding.mode === 'url-env') {
84
+ if (!exact(binding, ['kind', 'mode', 'environment', 'urlEnv', 'host', 'port', 'database', 'user'])
85
+ || !/^[A-Z][A-Z0-9_]*$/.test(binding.urlEnv ?? '') || !host(binding.host) || !port(binding.port)
86
+ || !label(binding.database) || !label(binding.user)) denied();
87
+ } else denied();
88
+ } else denied();
89
+ return Object.freeze(binding);
90
+ }
91
+
92
+ /** Require the selected sealed transport before any fallback can reach an operator's Docker or SSH context. */
93
+ export function bindStackTarget(ctx, config, binding) {
94
+ if (!binding) return config;
95
+ if (binding.kind !== 'docker-swarm' || binding.environment !== config.env || binding.stack !== config.stack
96
+ || !config.remote.sealed || ctx.env.DOCKER_HOST || ctx.env.DOCKER_CONTEXT || ctx.env.GITHUB_ACTIONS
97
+ || ctx.env.STARCI_STACK_SEALED_SSH !== undefined) denied();
98
+ for (const member of Object.values(config.remote.sealed)) {
99
+ const file = path.join(config.secretsDir, `${member}.enc`);
100
+ assertBoundCustody(config.repoRoot, file);
101
+ }
102
+ return { ...config, nativeTarget: binding };
103
+ }
104
+
105
+ /** Compare the effective private SSH account before key files, sessions or network effects exist. */
106
+ export function assertStackAccount(binding, account, knownHosts) {
107
+ if (!binding) return;
108
+ if (account !== `${binding.user}@${binding.host}` || binding.port !== 22 || !knownHosts.trim()) denied();
109
+ }
110
+
111
+ /** Compare the effective local Supabase identity after its existing config/environment resolver. */
112
+ export function assertLocalDatabase(binding, target) {
113
+ if (binding && (binding.mode !== 'local' || binding.localProject !== target.project || binding.port !== target.port)) denied();
114
+ }
115
+
116
+ /** Refuse a selected URL unless its non-secret endpoint exactly matches the accepted target. */
117
+ export function assertUrlDatabase(binding, value) {
118
+ if (!binding) return;
119
+ let url;
120
+ try { url = new URL(value); } catch { denied(); }
121
+ if (binding.mode !== 'url-env' || !['postgres:', 'postgresql:'].includes(url.protocol)
122
+ || url.hostname !== binding.host || Number(url.port || '5432') !== binding.port
123
+ || decodeURIComponent(url.username) !== binding.user || decodeURIComponent(url.pathname.slice(1)) !== binding.database
124
+ || url.search || url.hash || !url.password) denied();
125
+ }