@starci/hfs 4.7.11 → 4.7.13
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/CHANGELOG.md +8 -0
- package/package.json +1 -1
- package/release/gate.mjs +388 -378
- package/release/lint-report.mjs +52 -0
- package/runtime/engine/digest.mjs +16 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/runtime/knowledge/hfs/ci-standard.yaml +1 -1
- package/runtime/packages/cli/src/catalog.generated.mjs +7 -4
- package/runtime/scripts/api/fs/is-link-like.mjs +1 -0
- package/runtime/scripts/api/sops/lib.mjs +5 -1
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +6 -4
- package/runtime/scripts/hfs/architecture/tiers.mjs +1 -2
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +1 -1
- package/runtime/scripts/hfs/ci-declaration.mjs +5 -2
- package/runtime/scripts/hfs/db-baseline-verb.mjs +5 -2
- package/runtime/scripts/hfs/db-plan-verb.mjs +4 -5
- package/runtime/scripts/hfs/doc-commands.mjs +2 -1
- package/runtime/scripts/hfs/rule-catalog.mjs +8 -4
- package/runtime/scripts/hfs/rules/coverage-below.mjs +5 -3
- package/runtime/scripts/hfs/rules/database-access.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-migrations.mjs +13 -3
- package/runtime/scripts/hfs/rules/database-plpgsql.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-queues.mjs +2 -2
- package/runtime/scripts/hfs/rules/deploy-scan.mjs +50 -6
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +11 -1
- package/runtime/scripts/hfs/rules/doc-fresh.mjs +19 -4
- package/runtime/scripts/hfs/rules/docker-health.mjs +4 -1
- package/runtime/scripts/hfs/rules/env-parity.mjs +8 -7
- package/runtime/scripts/hfs/rules/env-value-shape.mjs +2 -4
- package/runtime/scripts/hfs/rules/fe-build-args.mjs +1 -1
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +1 -1
- package/runtime/scripts/hfs/rules/release-provenance.mjs +9 -6
- package/runtime/scripts/hfs/rules/script-hygiene.mjs +23 -7
- package/runtime/scripts/hfs/rules/stack-tooling.mjs +5 -2
- package/runtime/scripts/hfs/rules/stacks-release.mjs +1 -1
- package/runtime/scripts/hfs/rules/stacks-swarm.mjs +6 -6
- package/runtime/scripts/hfs/rules/supply-chain.mjs +4 -3
- package/runtime/scripts/hfs/rules/unused-symbol-properties.mjs +1 -1
- package/runtime/scripts/hfs/rules/unused-symbol.mjs +8 -4
- package/runtime/scripts/hfs/rules/workflow-contexts.mjs +1 -1
- package/runtime/scripts/hfs/sealed-members.mjs +1 -1
- package/runtime/scripts/hfs/secret-bootstrap-crypto.mjs +2 -2
- package/runtime/scripts/hfs/secret-files.mjs +1 -1
- package/runtime/scripts/hfs/secret-sops.mjs +1 -1
- package/runtime/scripts/hfs/stack-deploy-config.mjs +5 -2
- package/runtime/scripts/lib/env.mjs +2 -1
- package/runtime/scripts/lib/path-key.mjs +3 -1
- package/runtime/scripts/lib/roots.mjs +3 -3
- package/runtime/scripts/lib/secret-patterns.mjs +2 -2
- package/runtime/scripts/lib/swarm-stack.mjs +4 -2
- package/runtime/scripts/lib/ts-analysis.mjs +8 -4
- package/sync/ci-commands.mjs +275 -275
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// lint-report.mjs - the external issues report the release gate's sonar row produces before it runs the scanner.
|
|
2
|
+
// The app declares `sonar.externalIssuesReportPaths=reports/lint.sonar.json`; the managed CI makes it with `lint --side <side> --sonar reports/lint.<side>.sonar.json`
|
|
3
|
+
// per lint matrix side and a "merge Sonar lint reports" step. This module does the same locally, through the same process seam as every gate
|
|
4
|
+
// (an argument array through ctx.spawn, never a shell). The merge below is the one in packages/hfs/sync/ci-commands.mjs (sonarMergeSteps): keep them identical.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { firstLine, tool } from '../stack/context.mjs';
|
|
8
|
+
|
|
9
|
+
const LINT_SIDES = ['be', 'fe'];
|
|
10
|
+
const MERGED_REPORT = 'reports/lint.sonar.json';
|
|
11
|
+
const sideReport = (side) => `reports/lint.${side}.sonar.json`;
|
|
12
|
+
|
|
13
|
+
/** CI merge: rules unique by id (last one wins, first position kept), issues concatenated in side order. */
|
|
14
|
+
function mergeSonarReports(reports) {
|
|
15
|
+
const rules = [...new Map(reports.flatMap((report) => report.rules).map((rule) => [rule.id, rule])).values()];
|
|
16
|
+
const issues = reports.flatMap((report) => report.issues);
|
|
17
|
+
return { rules, issues };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The sides the app has: a be/ or fe/ folder beside the app root. */
|
|
21
|
+
const sidesOf = (root) => LINT_SIDES.filter((side) => fs.existsSync(path.join(root, side)));
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Runs `starci app lint --side <side> --sonar <file>` for every side the app has, then writes reports/lint.sonar.json.
|
|
25
|
+
* `launch` turns an argv into [command, args] for the platform. Returns { pass, text }: the honest log of every lint run and the verdict.
|
|
26
|
+
* A lint that exits non-zero (findings or error) or writes no report fails the row; nothing is skipped.
|
|
27
|
+
*/
|
|
28
|
+
export function produceLintReports(ctx, { launch, platform, env }) {
|
|
29
|
+
const sides = sidesOf(ctx.cwd);
|
|
30
|
+
const out = [];
|
|
31
|
+
if (sides.length === 0) return { pass: false, text: `no be/ or fe/ side in ${ctx.cwd}: cannot produce ${MERGED_REPORT}\n` };
|
|
32
|
+
fs.mkdirSync(path.join(ctx.cwd, 'reports'), { recursive: true });
|
|
33
|
+
for (const side of sides) {
|
|
34
|
+
const file = path.join(ctx.cwd, sideReport(side));
|
|
35
|
+
fs.rmSync(file, { force: true });
|
|
36
|
+
const argv = ['npx', 'starci', 'app', 'lint', '--side', side, '--sonar', sideReport(side)];
|
|
37
|
+
const [command, args] = launch(argv, platform);
|
|
38
|
+
out.push(`$ ${argv.join(' ')}\n`);
|
|
39
|
+
const run = tool(ctx, command, args, { cwd: ctx.cwd, env });
|
|
40
|
+
out.push(`${run.stdout ?? ''}${run.stderr ?? ''}`);
|
|
41
|
+
if (run.error) return { pass: false, text: `${out.join('')}\nlint ${side} cannot run (${run.error.message})\n` };
|
|
42
|
+
if (run.status !== 0) return { pass: false, text: `${out.join('')}\nlint ${side} failed: exit ${run.status}; the scanner was not run\n` };
|
|
43
|
+
if (!fs.existsSync(file)) return { pass: false, text: `${out.join('')}\nlint ${side} wrote no ${sideReport(side)}; the scanner was not run\n` };
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const reports = sides.map((side) => JSON.parse(fs.readFileSync(path.join(ctx.cwd, sideReport(side)), 'utf8')));
|
|
47
|
+
fs.writeFileSync(path.join(ctx.cwd, MERGED_REPORT), `${JSON.stringify(mergeSonarReports(reports), null, 2)}\n`);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return { pass: false, text: `${out.join('')}\ncannot merge the side reports: ${firstLine(error.message)}\n` };
|
|
50
|
+
}
|
|
51
|
+
return { pass: true, text: `${out.join('')}\nwrote ${MERGED_REPORT} from ${sides.map(sideReport).join(', ')}\n\n` };
|
|
52
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// digest.mjs — the one SHA-256 the runtime hashes with. A leaf: every file's integrity digest,
|
|
2
|
+
// every ledger input ref and every stamped asset is this function, so a byte stream and a file
|
|
3
|
+
// never disagree about what their digest is.
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
|
|
7
|
+
/** The lowercase hex SHA-256 of `value` (string, Buffer or TypedArray). */
|
|
8
|
+
export const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
|
9
|
+
/** The SHA-256 of the file's exact bytes; throws when the file cannot be read. */
|
|
10
|
+
export function sha256File(file){
|
|
11
|
+
// A numeric descriptor belongs to the caller; positional reads preserve its offset.
|
|
12
|
+
const owned=typeof file!=='number',fd=owned?fs.openSync(file,'r'):file;
|
|
13
|
+
const hash=createHash('sha256'),buffer=Buffer.alloc(1024*1024);
|
|
14
|
+
try{let at=0,n;while((n=fs.readSync(fd,buffer,0,buffer.length,at))>0){hash.update(buffer.subarray(0,n));at+=n;}return hash.digest('hex');}
|
|
15
|
+
finally{if(owned)fs.closeSync(fd);}
|
|
16
|
+
}
|
|
@@ -29,14 +29,14 @@ pins:
|
|
|
29
29
|
source: packages/grammar/package.json
|
|
30
30
|
why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
|
|
31
31
|
'@starci/eslint-canon-be':
|
|
32
|
-
version: 3.4.
|
|
32
|
+
version: 3.4.20
|
|
33
33
|
group: starci
|
|
34
34
|
install: registry
|
|
35
35
|
side: be
|
|
36
36
|
source: packages/eslint/be/package.json
|
|
37
37
|
why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
|
|
38
38
|
'@starci/eslint-canon-fe':
|
|
39
|
-
version: 8.4.
|
|
39
|
+
version: 8.4.19
|
|
40
40
|
group: starci
|
|
41
41
|
install: registry
|
|
42
42
|
side: fe
|
|
@@ -76,14 +76,14 @@ pins:
|
|
|
76
76
|
source: packages/test-world/package.json
|
|
77
77
|
why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
|
|
78
78
|
'@starci/cli':
|
|
79
|
-
version: 1.7.
|
|
79
|
+
version: 1.7.13
|
|
80
80
|
group: starci
|
|
81
81
|
install: registry
|
|
82
82
|
side: both
|
|
83
83
|
source: packages/cli/package.json
|
|
84
84
|
why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
|
|
85
85
|
'@starci/hfs':
|
|
86
|
-
version: 4.7.
|
|
86
|
+
version: 4.7.13
|
|
87
87
|
group: starci
|
|
88
88
|
install: registry
|
|
89
89
|
side: both
|
|
@@ -27,7 +27,7 @@ defaults:
|
|
|
27
27
|
# The steps that need a raised Node heap on the GitHub runner, matched on their `run` text, and the least heap they must declare in NODE_OPTIONS.
|
|
28
28
|
heap:
|
|
29
29
|
minMiB: 6144
|
|
30
|
-
steps: ['npm run typecheck', 'npm run lint', 'app lint', 'app check --coverage-from']
|
|
30
|
+
steps: ['npm run typecheck', 'npm run lint', 'app lint', 'app check --coverage-from', 'npm run db:lint']
|
|
31
31
|
|
|
32
32
|
items:
|
|
33
33
|
- id: candidate-trigger
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// GENERATED by scripts/cli/gen-catalog.mjs — do not edit
|
|
2
2
|
export const CATALOG = {
|
|
3
3
|
"schema": "starci/cli-catalog@1",
|
|
4
|
-
"hash": "
|
|
4
|
+
"hash": "8913c2eedad972f96410ed412622ca511f32f9a4f2b5fb8104b745e310007ac5",
|
|
5
5
|
"global": [
|
|
6
6
|
{
|
|
7
7
|
"name": "json",
|
|
@@ -10894,7 +10894,8 @@ export const CATALOG = {
|
|
|
10894
10894
|
"starci supabase start --cwd <app>"
|
|
10895
10895
|
],
|
|
10896
10896
|
"editions": [
|
|
10897
|
-
"full"
|
|
10897
|
+
"full",
|
|
10898
|
+
"lite"
|
|
10898
10899
|
],
|
|
10899
10900
|
"impl": {
|
|
10900
10901
|
"module": "scripts/machine/supabase-stack.mjs",
|
|
@@ -10931,7 +10932,8 @@ export const CATALOG = {
|
|
|
10931
10932
|
"starci supabase status --cwd <app> --json"
|
|
10932
10933
|
],
|
|
10933
10934
|
"editions": [
|
|
10934
|
-
"full"
|
|
10935
|
+
"full",
|
|
10936
|
+
"lite"
|
|
10935
10937
|
],
|
|
10936
10938
|
"impl": {
|
|
10937
10939
|
"module": "scripts/machine/supabase-stack.mjs",
|
|
@@ -10973,7 +10975,8 @@ export const CATALOG = {
|
|
|
10973
10975
|
"starci supabase stop --cwd <app> --no-backup"
|
|
10974
10976
|
],
|
|
10975
10977
|
"editions": [
|
|
10976
|
-
"full"
|
|
10978
|
+
"full",
|
|
10979
|
+
"lite"
|
|
10977
10980
|
],
|
|
10978
10981
|
"impl": {
|
|
10979
10982
|
"module": "scripts/machine/supabase-stack.mjs",
|
|
@@ -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
|
-
/**
|
|
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 = '';
|
|
@@ -106,7 +106,7 @@ function queueMethods(env, declaration, constructor, excluded) {
|
|
|
106
106
|
&& decoratorsHave(env, parameter, 'InjectQueueOutbox', '@modules/platform/queue'));
|
|
107
107
|
if (!outbox || !env.ts.isIdentifier(outbox.name)) return;
|
|
108
108
|
for (const method of declaration.members) {
|
|
109
|
-
if (!env.ts.isMethodDeclaration(method) ||
|
|
109
|
+
if (!env.ts.isMethodDeclaration(method) || method.body?.statements.length !== 1) continue;
|
|
110
110
|
const call = statementCall(env.ts, method.body.statements[0]);
|
|
111
111
|
if (call && env.ts.isCallExpression(call) && receiverMember(env.ts, call) === outbox.name.text && call.expression.name.text === 'write'
|
|
112
112
|
&& call.arguments.length === 3 && call.arguments.every(argument => mapping(env, argument))) excluded.add(method);
|
|
@@ -62,11 +62,13 @@ function catalogsPerApp(config, graph) {
|
|
|
62
62
|
|
|
63
63
|
/** Each interpolation can fill part or all of a segment; the template's static fragments keep their literal spelling. */
|
|
64
64
|
function templatePatternOf(node) {
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
const pattern = [];
|
|
66
|
+
for (const segment of [node.head.text, ...node.templateSpans.map(span => span.literal.text)].join('\u0000').split('.')) {
|
|
67
|
+
if (!segment.includes('\u0000')) { pattern.push(segment); continue; }
|
|
67
68
|
const parts = segment.split('\u0000');
|
|
68
|
-
|
|
69
|
-
}
|
|
69
|
+
pattern.push(parts.every(part => part === '') ? '*' : new RegExp(`^${parts.map((part) => escapeRegExp(part)).join('.*')}$`, 'su'));
|
|
70
|
+
}
|
|
71
|
+
return pattern;
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
function patternsOf(ts, files) {
|
|
@@ -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
|
-
|
|
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);
|
|
@@ -94,7 +94,7 @@ function moduleSpecifierOf(ts, node) {
|
|
|
94
94
|
function isApolloClientConstruction(ts, kit, checker, node) {
|
|
95
95
|
if (!ts.isNewExpression(node)) return false;
|
|
96
96
|
const binding = kit.importBinding(checker, node.expression);
|
|
97
|
-
return Boolean(binding
|
|
97
|
+
return Boolean(binding?.name === 'ApolloClient' && binding.module.startsWith('@apollo/client'));
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
function inspectLibraryImport({ ts, file, node, transport, report }) {
|
|
@@ -60,7 +60,10 @@ export function qualityServices(doc, { example = false } = {}) {
|
|
|
60
60
|
/** Enumerate a missing directory as empty; all filesystem access uses the caller's injected synchronous seam. */
|
|
61
61
|
function entries(dir, fs) {
|
|
62
62
|
try { return fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); }
|
|
63
|
-
catch (error) {
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (error.code === 'ENOENT') return [];
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
/** Read only encrypted envelope key names, never decrypting or returning any sealed value. */
|
|
@@ -70,7 +73,7 @@ export function sealedKeyNames(root, { fs }) {
|
|
|
70
73
|
for (const member of entries(path.join(base, env.name, 'secrets'), fs).filter((entry) => entry.isFile() && entry.name.endsWith('.enc'))) {
|
|
71
74
|
const file = `.starcistacks/${env.name}/secrets/${member.name}`;
|
|
72
75
|
const source = fs.readFileSync(path.join(root, file), 'utf8');
|
|
73
|
-
const dotenv = [...source.matchAll(/^([A-Za-z_]
|
|
76
|
+
const dotenv = [...source.matchAll(/^([A-Za-z_]\w*)=ENC\[/gm)].map((match) => match[1]).filter((key) => !key.startsWith('sops_'));
|
|
74
77
|
let keys = dotenv;
|
|
75
78
|
if (!dotenv.length) {
|
|
76
79
|
const doc = parseYaml(source);
|
|
@@ -87,7 +87,7 @@ function baselinePlan(root, opts, now) {
|
|
|
87
87
|
if (!active.length) throw new DbVerbError('baseline requires an existing active migration chain');
|
|
88
88
|
const archived = dbSqlFiles(root, MIGRATIONS_ARCHIVE_DIR);
|
|
89
89
|
const oldStamps = [...active, ...archived].map((entry) => entry.stamp);
|
|
90
|
-
const last = oldStamps.
|
|
90
|
+
const last = oldStamps.toSorted((left, right) => left.localeCompare(right)).at(-1);
|
|
91
91
|
const stamp = opts.stamp ?? now().toISOString().replace(/\D/g, '').slice(0, 14);
|
|
92
92
|
if (!/^\d{14}$/.test(stamp) || migrationStamp(stamp) === null || stamp <= last) throw new DbVerbError('--stamp must be a valid UTC version after the archive boundary');
|
|
93
93
|
const file = `${MIGRATIONS_DIR}/${stamp}_baseline.sql`;
|
|
@@ -225,7 +225,10 @@ function extensionDeclaration(stmt, sql, extensions) {
|
|
|
225
225
|
|
|
226
226
|
function baselineStatement(state, stmt, sql) {
|
|
227
227
|
if (roleDeclaration(stmt, sql, state.roles) || extensionDeclaration(stmt, sql, state.extensions)) return;
|
|
228
|
-
if (stmt.DropRoleStmt) {
|
|
228
|
+
if (stmt.DropRoleStmt) {
|
|
229
|
+
for (const role of stmt.DropRoleStmt.roles ?? []) state.roles.delete(role.RoleSpec?.rolename);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
229
232
|
if (stmt.GrantRoleStmt || stmt.AlterExtensionStmt || stmt.AlterExtensionContentsStmt || /^OBJECT_ROLE$/.test(stmt.RenameStmt?.renameType ?? '')) throw new DbVerbError('baseline cannot reconstruct role membership, role renames or extension alterations');
|
|
230
233
|
if (dataMutation(stmt) || sourceExecution(stmt)) throw new DbVerbError('baseline cannot reconstruct migration-owned data changes or custom execution from a schema-only dump');
|
|
231
234
|
const node = Object.values(stmt)[0];
|
|
@@ -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 {
|
|
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.
|
|
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:
|
|
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]{
|
|
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);
|
|
@@ -29,7 +29,8 @@ const inner = (t) => t.replace(/^<|>$/g, '');
|
|
|
29
29
|
|
|
30
30
|
// ---- extraction -----------------------------------------------------------------------------------------------
|
|
31
31
|
|
|
32
|
-
const
|
|
32
|
+
const COMMAND_FORM = /npx\s+starci|npm\s+run\s+starci(?:\s+--silent)?(?:\s+--)?|starci/;
|
|
33
|
+
const COMMAND_AT = new RegExp(String.raw`(?<![\w@./:-])(?:${COMMAND_FORM.source})(?![\w@./-])(?=\s|$)`, 'g');
|
|
33
34
|
const FENCE = /^\s*(?:[-*>|]\s*)?```/;
|
|
34
35
|
const SCRIPT_COMMENT = /^\s*(?:\/\/|\*|\/\*)/;
|
|
35
36
|
const CONTINUED = /\\\s*$/;
|
|
@@ -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';
|
|
@@ -20,7 +21,10 @@ const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'fail
|
|
|
20
21
|
const CASE_KEYS = new Set(['id', 'date', 'source', 'ruling', 'quote', 'supersedes', 'status', 'staleWhen', 'amendedBy']);
|
|
21
22
|
const CASE_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
22
23
|
const ENFORCER_KEYS = new Set(['kind', 'id', 'status', 'at', 'editions', 'editionWhy']);
|
|
23
|
-
const
|
|
24
|
+
const CASE_ID = /case-[1-9]\d*/;
|
|
25
|
+
const RULING_REFERENCE = new RegExp(String.raw`(?:ruling:)?R\d{2,3}(?:/${CASE_ID.source})?`);
|
|
26
|
+
const OWNER_REFERENCE = /owner:[a-z][a-z0-9]*(?:-[a-z0-9]+)*/;
|
|
27
|
+
const CASE_REFERENCE = new RegExp(`^(?:${CASE_ID.source}|${RULING_REFERENCE.source}|${OWNER_REFERENCE.source})$`);
|
|
24
28
|
const EVIDENCE_KEYS = new Set(['path', 'edition', 'editions']);
|
|
25
29
|
const PROOF_KEYS = new Set([...EVIDENCE_KEYS, 'codes', 'fixtures']);
|
|
26
30
|
const WITNESS_KEYS = new Set(['edition', 'base', 'mutate', 'expect']);
|
|
@@ -39,7 +43,7 @@ function relativePathProblem(value, label, bad, anchor = false) {
|
|
|
39
43
|
const parts = typeof value === 'string' ? value.split('#') : [];
|
|
40
44
|
const file = parts[0];
|
|
41
45
|
const valid = typeof value === 'string' && value.trim() === value && value.length > 0
|
|
42
|
-
&& !/[\r\n
|
|
46
|
+
&& !value.includes('\0') && !/[\r\n\\]/.test(value) && !/^(?:\/|[a-z][a-z0-9+.-]*:)/i.test(value) && !file.includes(':')
|
|
43
47
|
&& !file.split('/').some((part) => ['', '.', '..'].includes(part))
|
|
44
48
|
&& (parts.length === 1 || (anchor && parts.length === 2 && /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/.test(parts[1])));
|
|
45
49
|
if (!valid) bad.push(`${label} must be a repository-relative path${anchor ? ' with an optional #anchor' : ''}`);
|
|
@@ -51,7 +55,7 @@ function unknownKeys(item, allowed, label, bad) {
|
|
|
51
55
|
|
|
52
56
|
function nonEmptyList(value, label, bad) {
|
|
53
57
|
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));
|
|
58
|
+
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
59
|
if (new Set(normalized).size !== value.length) bad.push(`${label} repeats a value`);
|
|
56
60
|
return true;
|
|
57
61
|
}
|
|
@@ -190,7 +194,7 @@ function checkRuleCases(rule, label, bad) {
|
|
|
190
194
|
const at = `${label}.cases[${index}]`;
|
|
191
195
|
if (!isPlainObject(item)) { bad.push(`${at} is not a map`); return; }
|
|
192
196
|
for (const key of Object.keys(item)) if (!CASE_KEYS.has(key)) bad.push(`${at} has unknown key ${key}`);
|
|
193
|
-
if (typeof item.id !== 'string' || !/^case-[1-9]
|
|
197
|
+
if (typeof item.id !== 'string' || !/^case-[1-9]\d*$/.test(item.id) || ids.has(item.id)) bad.push(`${at}.id must be a unique case-N`);
|
|
194
198
|
ids.add(item.id);
|
|
195
199
|
if (typeof item.date !== 'string' || !CASE_DATE.test(item.date)) bad.push(`${at}.date must be YYYY-MM-DD`);
|
|
196
200
|
if (item.source !== 'owner') bad.push(`${at}.source must be owner`);
|
|
@@ -12,7 +12,6 @@ import { found } from './read.mjs';
|
|
|
12
12
|
export const HFS_COVERAGE_BELOW = 'HFS_COVERAGE_BELOW';
|
|
13
13
|
const METRICS = ['lines', 'functions', 'branches'];
|
|
14
14
|
const SUMMARY = { LF: ['lines', 'found'], LH: ['lines', 'hit'], FNF: ['functions', 'found'], FNH: ['functions', 'hit'], BRF: ['branches', 'found'], BRH: ['branches', 'hit'] };
|
|
15
|
-
const SOURCE = /\.ts$/;
|
|
16
15
|
const SPEC = /(?:\.(?:spec|test)|-spec)\.ts$/;
|
|
17
16
|
const countOf = (value) => /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : null;
|
|
18
17
|
const slash = (value) => path.posix.normalize(String(value).replaceAll('\\', '/'));
|
|
@@ -66,7 +65,10 @@ function parseLcov(text) {
|
|
|
66
65
|
const records = [];
|
|
67
66
|
const problems = [];
|
|
68
67
|
let current = null;
|
|
69
|
-
const flush = () => {
|
|
68
|
+
const flush = () => {
|
|
69
|
+
if (current) records.push(current);
|
|
70
|
+
current = null;
|
|
71
|
+
};
|
|
70
72
|
for (const [index, raw] of String(text).replace(/^\uFEFF/, '').split(/\r?\n/).entries()) {
|
|
71
73
|
const line = raw.trim();
|
|
72
74
|
if (!line) continue;
|
|
@@ -102,7 +104,7 @@ function inventory({ files, all, repo, manifest }) {
|
|
|
102
104
|
const scope = coverageScope(effective, repo.providers ?? repo.sides?.be?.providers ?? []);
|
|
103
105
|
const roots = scope.roots.map((dir) => globExpression(`${dir}**`));
|
|
104
106
|
const excludes = scope.excludes.map((dir) => globExpression(`${dir}**`));
|
|
105
|
-
const candidates = [...new Set((all ?? files ?? []).map(slash))].filter((file) => file.startsWith(prefix) &&
|
|
107
|
+
const candidates = [...new Set((all ?? files ?? []).map(slash))].filter((file) => file.startsWith(prefix) && file.endsWith('.ts') && !SPEC.test(file)).sort(byCodeUnit);
|
|
106
108
|
const measured = [];
|
|
107
109
|
const unmeasured = [];
|
|
108
110
|
for (const file of candidates) {
|
|
@@ -74,7 +74,7 @@ async function principalHelpers(analyses) {
|
|
|
74
74
|
try { bodies.push({ name: fn.name, ast: await parseSql(body) }); } catch { /* PL/pgSQL safety is checked separately. */ }
|
|
75
75
|
});
|
|
76
76
|
const helpers = new Set();
|
|
77
|
-
for (let
|
|
77
|
+
for (let remaining = bodies.length; remaining > 0; remaining -= 1) {
|
|
78
78
|
for (const body of bodies) if (hasPrincipal(body.ast, helpers)) helpers.add(body.name);
|
|
79
79
|
}
|
|
80
80
|
return helpers;
|
|
@@ -13,7 +13,10 @@ import { fileURLToPath } from 'node:url';
|
|
|
13
13
|
import { loadTypescript } from '../../lib/package-at.mjs';
|
|
14
14
|
|
|
15
15
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
-
const
|
|
16
|
+
const MIGRATION_PLAIN = /[a-z0-9]+(?:-[a-z0-9]+)*/;
|
|
17
|
+
const MIGRATION_CONTRACT_TAIL = /contract_[a-z0-9]+(?:[-_][a-z0-9]+)*/;
|
|
18
|
+
const MIGRATION_SLUG = [MIGRATION_PLAIN.source, MIGRATION_CONTRACT_TAIL.source, `${MIGRATION_PLAIN.source}_${MIGRATION_CONTRACT_TAIL.source}`].join('|');
|
|
19
|
+
const MIGRATION_NAME = new RegExp(String.raw`^(\d{14})_(${MIGRATION_SLUG})\.sql$`);
|
|
17
20
|
export const isMigrationName = (name) => MIGRATION_NAME.test(name);
|
|
18
21
|
const RESTAMP_HEADER = /^-- starci:restamp (\d{14}) (\d{14})(?:\r?\n|$)/;
|
|
19
22
|
const withoutRestampHeader = (text) => text?.replace(RESTAMP_HEADER, '') ?? null;
|
|
@@ -227,7 +230,11 @@ export function databaseSqlTokens(text) {
|
|
|
227
230
|
let index = 0;
|
|
228
231
|
let line = 1;
|
|
229
232
|
while (index < text.length) {
|
|
230
|
-
if (/\s/.test(text[index])) {
|
|
233
|
+
if (/\s/.test(text[index])) {
|
|
234
|
+
if (text[index] === '\n') line += 1;
|
|
235
|
+
index += 1;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
231
238
|
const end = commentEnd(text, index);
|
|
232
239
|
if (end !== null) { line += text.slice(index, end).split('\n').length - 1; index = end; continue; }
|
|
233
240
|
const token = sqlTokenAt(text, index);
|
|
@@ -243,7 +250,10 @@ export function databaseSqlStatements(tokens) {
|
|
|
243
250
|
const statements = [];
|
|
244
251
|
let current = [];
|
|
245
252
|
for (const token of tokens) {
|
|
246
|
-
if (token.t === 'punct' && token.v === ';') {
|
|
253
|
+
if (token.t === 'punct' && token.v === ';') {
|
|
254
|
+
if (current.length) statements.push(current);
|
|
255
|
+
current = [];
|
|
256
|
+
}
|
|
247
257
|
else current.push(token);
|
|
248
258
|
}
|
|
249
259
|
if (current.length) statements.push(current);
|
|
@@ -290,7 +290,7 @@ async function functionFindings(file, fn, facts, exposed) {
|
|
|
290
290
|
|
|
291
291
|
function alteredDefinerFindings(file, alter, facts, exposed) {
|
|
292
292
|
if (!alter.securityDefiner) return [];
|
|
293
|
-
const created = facts.functions.
|
|
293
|
+
const created = facts.functions.findLast((fn) => fn.index < alter.index && matchesAlter(fn, alter));
|
|
294
294
|
const node = created?.node ?? { func: alter.node.func };
|
|
295
295
|
const fn = { ...alter, node, altered: true, originIndex: created?.index ?? -1 };
|
|
296
296
|
return definerDeclarationFindings(file, fn, facts, exposed, true);
|
|
@@ -104,7 +104,7 @@ function inspectDo(state, node, file) {
|
|
|
104
104
|
const option = (key) => (node.args ?? []).find((item) => item.DefElem?.defname === key)?.DefElem?.arg?.String?.sval;
|
|
105
105
|
const tree = plpgsqlTree(option('as') ?? '');
|
|
106
106
|
walk(tree, (item) => {
|
|
107
|
-
if (item.PLpgSQL_expr
|
|
107
|
+
if (item.PLpgSQL_expr?.parseMode === 0) inspectCalls(state, sqlTree(item.PLpgSQL_expr.query), file, true);
|
|
108
108
|
if (item.PLpgSQL_stmt_dynexecute) {
|
|
109
109
|
const expression = sqlTree(`select ${item.PLpgSQL_stmt_dynexecute.query?.PLpgSQL_expr?.query ?? ''}`);
|
|
110
110
|
dynamicOwnership(state, expression, file, true);
|
|
@@ -128,7 +128,7 @@ function grantCheck(state, node, file) {
|
|
|
128
128
|
function recordGrant(state, node, file, executed) {
|
|
129
129
|
state.grants.push({ node, file, created: new Map(state.created), executed });
|
|
130
130
|
const grant = node.GrantStmt;
|
|
131
|
-
if (!executed ||
|
|
131
|
+
if (!executed || grant?.objtype !== 'OBJECT_TABLE' || grant.targtype !== 'ACL_TARGET_OBJECT') return;
|
|
132
132
|
if (!(grant.grantees ?? []).some((item) => item.RoleSpec?.rolename === state.appRole)) return;
|
|
133
133
|
const privileges = grant.privileges?.map((item) => item.AccessPriv?.priv_name).filter(Boolean) ?? TABLE_PRIVILEGES;
|
|
134
134
|
for (const object of grant.objects ?? []) {
|
|
@@ -8,17 +8,61 @@ const DEPLOY = /(?:^|\/)deploy\//;
|
|
|
8
8
|
const SCRIPT = /(?:^|\/)scripts\//;
|
|
9
9
|
const COMPOSE = /(?:^|\/)(?:docker-)?compose(?:[.-][^/]*)?\.ya?ml$/;
|
|
10
10
|
const DOCKERFILE = /(?:^|\/)Dockerfile(?:\.[^/]*)?$/;
|
|
11
|
-
const
|
|
12
|
-
const
|
|
11
|
+
const CREDENTIAL_FILE_PARTS = [/(?:^|\/)secrets?\.env(?:\.[^/]*)?/, /(?:^|\/)\.env(?:\.[^/]*)?/, /(?:^|\/)secrets\//, /\.enc$/];
|
|
12
|
+
const CREDENTIAL_FILE = new RegExp(CREDENTIAL_FILE_PARTS.map((part) => part.source).join('|'), 'i');
|
|
13
|
+
const AT_JOB_ARGUMENT = /-[ft]\b|now\b|tomorrow\b|\d{1,2}:\d{2}\b|midnight\b|noon\b/.source;
|
|
14
|
+
const HOST_CRON_PARTS = [
|
|
15
|
+
/\b(?:crontab|anacron|crond|supercronic)\b/,
|
|
16
|
+
/\/etc\/cron(?:\b|[./])/,
|
|
17
|
+
/\bcron\.d\b/,
|
|
18
|
+
/\/var\/spool\/cron\b/,
|
|
19
|
+
/@(?:reboot|yearly|annually|monthly|weekly|daily|midnight|hourly)\b/,
|
|
20
|
+
/\b[\w.@-]+\.timer\b/,
|
|
21
|
+
/\bOn(?:Calendar|BootSec|UnitActiveSec|ActiveSec)\s*=/,
|
|
22
|
+
new RegExp(String.raw`(?:^|[\s;|&"'])at\s+(?:-[a-z]+\s+)*(?:${AT_JOB_ARGUMENT})`),
|
|
23
|
+
];
|
|
24
|
+
const HOST_CRON = new RegExp(HOST_CRON_PARTS.map((part) => part.source).join('|'), 'im');
|
|
13
25
|
const HOST_CRON_PATH = /(?:^|\/)(?:crontab(?:$|\/)|cron\.d(?:\/|$)|[^/]+\.(?:timer|crontab)$)/;
|
|
14
26
|
const CRON_NUMERIC_FIELD = /^[\d*,/-]+$/;
|
|
15
27
|
const CRON_NAMED_FIELD = /^[\w*,/-]+$/;
|
|
16
|
-
const
|
|
17
|
-
|
|
28
|
+
const CRON_CONTAINER_PARTS = [
|
|
29
|
+
/\bimage\s*:\s*["']?(?:[^\s"']*\/)?(?:cron|crond|supercronic|ofelia)(?=[:@\s"']|$)/,
|
|
30
|
+
/\b(?:command|entrypoint)\s*:[^\n]*\b(?:cron|crond|supercronic)\b/,
|
|
31
|
+
];
|
|
32
|
+
const CRON_CONTAINER = new RegExp(CRON_CONTAINER_PARTS.map((part) => part.source).join('|'), 'i');
|
|
33
|
+
const SLEEP_LOOP_HEAD_PARTS = [/while\s+(?:true|:|\[\s*1\s*\])/, /for\s*\(\(\s*;\s*;\s*\)\)/];
|
|
34
|
+
const SLEEP_LOOP_HEAD = new RegExp(String.raw`\b(?:${SLEEP_LOOP_HEAD_PARTS.map((part) => part.source).join('|')})`, 'gi');
|
|
35
|
+
const SLEEP_LOOP_BREAK = /[^\S\n]*[;\n]/y;
|
|
36
|
+
const SLEEP_LOOP_SLEEP = /\bsleep\s+/gi;
|
|
37
|
+
const SLEEP_LOOP_REACH = 4000;
|
|
38
|
+
/** An endless-loop head, a statement break, then a `sleep` within SLEEP_LOOP_REACH characters: searched in steps so no regex backtracks across the pair. */
|
|
39
|
+
const SLEEP_LOOP = {
|
|
40
|
+
test(text) {
|
|
41
|
+
for (const head of text.matchAll(SLEEP_LOOP_HEAD)) {
|
|
42
|
+
SLEEP_LOOP_BREAK.lastIndex = head.index + head[0].length;
|
|
43
|
+
if (!SLEEP_LOOP_BREAK.test(text)) continue;
|
|
44
|
+
SLEEP_LOOP_SLEEP.lastIndex = SLEEP_LOOP_BREAK.lastIndex;
|
|
45
|
+
const sleep = SLEEP_LOOP_SLEEP.exec(text);
|
|
46
|
+
if (sleep && sleep.index - SLEEP_LOOP_BREAK.lastIndex <= SLEEP_LOOP_REACH) return true;
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
18
51
|
const INLINE_NODE = /\bnode\b[^\n;&|]*?\s(?:-(?:e|p|pe)\b|--(?:eval|print)(?:\s|=))/;
|
|
19
|
-
const
|
|
52
|
+
const INLINE_SQL_ACTION_PARTS = [
|
|
53
|
+
/insert\s+into/,
|
|
54
|
+
/update\s+[\w."']+\s+set/,
|
|
55
|
+
/delete\s+from/,
|
|
56
|
+
/create\s+(?:table|function|view)/,
|
|
57
|
+
/alter\s+table/,
|
|
58
|
+
/truncate\s+(?:table\s+)?[\w."']/,
|
|
59
|
+
];
|
|
60
|
+
const INLINE_SQL_ACTION = new RegExp(String.raw`\b(?:${INLINE_SQL_ACTION_PARTS.map((part) => part.source).join('|')})\b`, 'i');
|
|
20
61
|
const REMOTE_SHELL = /\b(?:curl|wget)\b[^|\n]*\|\s*(?:sudo\s+)?(?:ba|da|z)?sh\b/i;
|
|
21
|
-
const
|
|
62
|
+
const DOCKER_GLOBAL_OPTION = /(?:--(?:context|host)(?:\s+|=)|-H\s+)\S+/.source;
|
|
63
|
+
const DOCKER_COMPOSE_COMMAND = new RegExp(String.raw`\bdocker(?:\s+${DOCKER_GLOBAL_OPTION})*\s+compose\b[^\n;&|]*`).source;
|
|
64
|
+
const DOCKER_COMPOSE_V1_COMMAND = /\bdocker-compose\b[^\n;&|]*/.source;
|
|
65
|
+
const COMPOSE_COMMAND = new RegExp(`${DOCKER_COMPOSE_COMMAND}|${DOCKER_COMPOSE_V1_COMMAND}`, 'g');
|
|
22
66
|
const COMPOSE_OPTIONS = new Set(['-f', '--file', '-p', '--project-name', '--project-directory', '--env-file', '--profile', '--parallel', '--ansi', '--progress']);
|
|
23
67
|
const DEV = /(?:^|[\s/._="'-])(?:dev|development|local)(?=$|[\s/._"'-])/i;
|
|
24
68
|
const NON_DEV = /(?:^|[\s/._="'-])(?:prod|production|staging|stage|vps|preview)(?=$|[\s/._"'-])/i;
|
|
@@ -59,7 +59,17 @@ const validPermissions = (value) => record(value) || value === 'read-all' || val
|
|
|
59
59
|
const validConcurrency = (value) => nonempty(value) || (record(value) && nonempty(value.group));
|
|
60
60
|
const deployName = (value) => !/authorize[\s_.-]+deploy[\s_.-]+key/i.test(value) && /(?:^|[\s/_.-])deploy(?:[\s/_.-]|$)/i.test(value);
|
|
61
61
|
const materializeCommand = (script) => commands(script).some(({ text }) => /^starci\s+app\s+secret\s+materialize\b/.test(commandBody(text)));
|
|
62
|
-
const
|
|
62
|
+
const DEPLOYMENT_COMMANDS = [
|
|
63
|
+
/^starci\s+(?:app\s+)?(?:deploy|secret\s+materialize)\b/,
|
|
64
|
+
/^docker\s+stack\s+deploy\b/,
|
|
65
|
+
/^(?:ba?sh\s+)?(?:\.\/)?deploy\//,
|
|
66
|
+
/^(?:ba?sh\s+)?(?:\.\/)?(?:scripts\/)?[\w.-]*deploy[\w.-]*\.sh\b/,
|
|
67
|
+
/^npm\s+run\s+deploy(?=[:\s]|$)/,
|
|
68
|
+
];
|
|
69
|
+
const deploymentCommand = (script) => commands(script).some(({ text }) => {
|
|
70
|
+
const body = commandBody(text);
|
|
71
|
+
return DEPLOYMENT_COMMANDS.some((pattern) => pattern.test(body));
|
|
72
|
+
});
|
|
63
73
|
|
|
64
74
|
function hasAgeIdentity(job, doc) {
|
|
65
75
|
const steps = activeSteps(job);
|