@starci/hfs 4.11.12 → 4.11.15
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 +31 -0
- package/README.md +1 -1
- package/db/migrate.mjs +1 -1
- package/db/postgres.mjs +38 -3
- package/package.json +1 -1
- package/release/index.mjs +6 -5
- package/release/lint-baseline.mjs +123 -0
- package/release/urgent.mjs +19 -4
- package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
- package/runtime/knowledge/hfs/rules.yaml +3 -3
- package/runtime/knowledge/patterns/be/webhooks.yaml +3 -2
- package/runtime/scripts/api/sops/lib.mjs +25 -3
- package/runtime/scripts/api/sops/seal.mjs +4 -3
- package/runtime/scripts/hfs/rules/canon-copy-drift.mjs +54 -7
- package/runtime/scripts/hfs/secret-init.mjs +15 -0
- package/runtime/scripts/hfs/secret-sops.mjs +65 -33
- package/runtime/scripts/lib/sops-envelope.mjs +36 -11
- package/scaffold/add-compose.mjs +3 -0
- package/scaffold/add-inbox-lite.mjs +45 -23
- package/scaffold/lite-exports.mjs +13 -0
- package/src/main.mjs +5 -5
- package/stack/age-install.mjs +10 -2
- package/stack/cli.mjs +5 -3
- package/stack/context.mjs +2 -2
- package/stack/deploy.mjs +6 -2
- package/stack/remote.mjs +29 -8
- package/stack/sealed.mjs +15 -11
- package/stack/server-key.mjs +31 -7
- package/stack/urgent-floor.mjs +101 -7
- package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +1 -1
- package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +1 -1
- package/templates/be/inbox/entities.index.ts.tpl +4 -0
- package/templates/be/inbox/entity.ts.tpl +29 -0
- package/templates/be/inbox/index.ts.tpl +1 -0
- package/templates/be/inbox/platform/inbox/inbox.decorators.ts.tpl +5 -2
- package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +29 -1
- package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +65 -1
- package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +48 -10
- package/templates/be/inbox/platform/inbox/index.ts.tpl +2 -2
- package/templates/be/inbox/service.spec.ts.tpl +39 -15
- package/templates/be/inbox/service.ts.tpl +13 -13
- package/templates/be/inbox/webhook-door.spec.ts.tpl +4 -1
- package/templates/be/skeleton/src/modules/domain/note/note.service.ts +3 -4
- package/templates/be/skeleton/src/modules/domain/note/persistence/note.rows.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/database/errors/database.error.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/database/index.ts +1 -0
- package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.spec.ts +84 -0
- package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.ts +61 -0
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.rows.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +3 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +41 -33
- package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.ts +15 -9
- package/templates/be/inbox/database-client.spec.ts.tpl +0 -45
- package/templates/be/inbox/database-client.ts.tpl +0 -45
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
// canon-copy-drift.mjs - HFS_CANON_COPY_DRIFT (R253): a handful of scaffolded files are hash-sensitive canon,
|
|
2
2
|
// not files to edit. The drift judgement is one sha256 against the bytes of the be/skeleton template the file was
|
|
3
|
-
// scaffolded from - the file equals canon, or it has drifted.
|
|
4
|
-
//
|
|
3
|
+
// scaffolded from - the file equals canon, or it has drifted. That template is the one of the @starci/hfs version the app
|
|
4
|
+
// pins (its lockfile's resolution of node_modules/@starci/hfs, else an exact package.json pin), never a newer hfs that
|
|
5
|
+
// happens to run the check: a skeleton change reaches an app only when the app raises its hfs. This package's own
|
|
6
|
+
// templates serve when it is that version or the app pins none; else an installed copy at the pinned version does; with
|
|
7
|
+
// neither at hand the copy is left to the app's own run of its pinned hfs. A finding carries the path and both hashes,
|
|
8
|
+
// never file content.
|
|
5
9
|
import crypto from 'node:crypto';
|
|
6
10
|
import fs from 'node:fs';
|
|
7
11
|
import path from 'node:path';
|
|
8
12
|
import { found } from './read.mjs';
|
|
9
13
|
|
|
10
14
|
const CANON_COPY = 'HFS_CANON_COPY_DRIFT';
|
|
11
|
-
const
|
|
15
|
+
const OWN_ROOT = path.resolve(import.meta.dirname, '../../../..');
|
|
16
|
+
const OWN_TEMPLATE_ROOTS = [
|
|
12
17
|
path.resolve(import.meta.dirname, '../../../packages/hfs/templates'),
|
|
13
|
-
path.
|
|
18
|
+
path.join(OWN_ROOT, 'templates'),
|
|
14
19
|
];
|
|
15
20
|
const TEMPLATE_DIR = 'be/skeleton/';
|
|
21
|
+
const HFS_LOCK_KEY = 'node_modules/@starci/hfs';
|
|
22
|
+
const EXACT = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
16
23
|
|
|
17
24
|
// The three managed files the standard's secret-custody law pins byte-for-byte.
|
|
18
25
|
const CANON_PATHS = [
|
|
@@ -23,8 +30,46 @@ const CANON_PATHS = [
|
|
|
23
30
|
const REGEX_ESCAPE = String.raw`\$&`;
|
|
24
31
|
const CANON_SUFFIX = new RegExp(`(?:^|/)(${CANON_PATHS.map((rel) => rel.replace(/[.*+?^${}()|[\]\\]/g, REGEX_ESCAPE)).join('|')})$`);
|
|
25
32
|
|
|
26
|
-
const
|
|
27
|
-
|
|
33
|
+
const readJson = (file) => {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const versionAt = (folder) => readJson(path.join(folder, 'package.json'))?.version;
|
|
41
|
+
|
|
42
|
+
/** The @starci/hfs version the app at `repoRoot` pins: its lockfile's resolution (hoisted first), else an exact package.json pin. */
|
|
43
|
+
export function pinnedHfsVersion(repoRoot) {
|
|
44
|
+
const packages = readJson(path.join(repoRoot, 'package-lock.json'))?.packages ?? {};
|
|
45
|
+
const locked = Object.keys(packages)
|
|
46
|
+
.filter((key) => key === HFS_LOCK_KEY || key.endsWith(`/${HFS_LOCK_KEY}`))
|
|
47
|
+
.sort((left, right) => left.length - right.length)
|
|
48
|
+
.map((key) => packages[key]?.version)
|
|
49
|
+
.find((version) => typeof version === 'string');
|
|
50
|
+
if (locked) return locked;
|
|
51
|
+
const manifest = readJson(path.join(repoRoot, 'package.json')) ?? {};
|
|
52
|
+
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) {
|
|
53
|
+
const spec = manifest[field]?.['@starci/hfs'];
|
|
54
|
+
if (typeof spec === 'string' && EXACT.test(spec)) return spec;
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The template roots of the app's pinned @starci/hfs, or null when no copy of that version is at hand. */
|
|
60
|
+
export function pinnedTemplateRoots(repoRoot) {
|
|
61
|
+
const pin = pinnedHfsVersion(repoRoot);
|
|
62
|
+
if (pin === undefined || versionAt(OWN_ROOT) === pin) return OWN_TEMPLATE_ROOTS;
|
|
63
|
+
const copies = [
|
|
64
|
+
path.join(repoRoot, 'node_modules', '@starci', 'hfs'),
|
|
65
|
+
path.join(repoRoot, 'node_modules', 'starci-harness', 'node_modules', '@starci', 'hfs'),
|
|
66
|
+
];
|
|
67
|
+
const installed = copies.find((copy) => versionAt(copy) === pin);
|
|
68
|
+
return installed === undefined ? null : [path.join(installed, 'templates')];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const templateBytes = (roots, rel) => {
|
|
72
|
+
for (const root of roots) {
|
|
28
73
|
const file = path.join(root, TEMPLATE_DIR, rel);
|
|
29
74
|
try {
|
|
30
75
|
const bytes = fs.readFileSync(file);
|
|
@@ -37,10 +82,12 @@ const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex'
|
|
|
37
82
|
|
|
38
83
|
export function canonCopyDriftFindings({ repoRoot, files, inScope }) {
|
|
39
84
|
const findings = [];
|
|
85
|
+
const roots = pinnedTemplateRoots(repoRoot);
|
|
86
|
+
if (roots === null) return findings;
|
|
40
87
|
for (const file of files.filter((f) => !inScope || inScope(f))) {
|
|
41
88
|
const hit = CANON_SUFFIX.exec(file.replaceAll('\\', '/'));
|
|
42
89
|
if (!hit) continue;
|
|
43
|
-
const expected = templateBytes(hit[1]);
|
|
90
|
+
const expected = templateBytes(roots, hit[1]);
|
|
44
91
|
if (!expected) continue;
|
|
45
92
|
let actual;
|
|
46
93
|
try {
|
|
@@ -106,6 +106,21 @@ export function localAgeIdentity(repoRoot, env, tools, host = null) {
|
|
|
106
106
|
return { env: { ...env }, source: null, looked };
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* The one age identity every decrypt of a sealed member opens with (secret get, verify, materialize, set over an existing member,
|
|
111
|
+
* migrate, split, the edit verbs, updatekeys, server-key, the stack's sealed members and the CLI's custody): localAgeIdentity's
|
|
112
|
+
* order (the environment, the app's `.claude/secret.env`, its main checkout's, the `host` port), then sops' per-user keys.txt.
|
|
113
|
+
* Same shape as localAgeIdentity; `looked` names every place read, keys.txt last.
|
|
114
|
+
*/
|
|
115
|
+
export function decryptionIdentity(repoRoot, env, { tools, host = null, platform = process.platform } = {}) {
|
|
116
|
+
const local = localAgeIdentity(repoRoot, env, tools, host);
|
|
117
|
+
if (local.source !== null) return local;
|
|
118
|
+
const keys = secretRoots(env, platform).identity;
|
|
119
|
+
const looked = [...local.looked, keys];
|
|
120
|
+
if (!fs.existsSync(keys)) return { ...local, looked };
|
|
121
|
+
return { env: { ...local.env, SOPS_AGE_KEY_FILE: keys }, source: keys, looked };
|
|
122
|
+
}
|
|
123
|
+
|
|
109
124
|
/** Load host-local age selections below the explicit environment (localAgeIdentity), without copying any other credentials. */
|
|
110
125
|
export function ageIdentityEnv(repoRoot, env, tools) {
|
|
111
126
|
return localAgeIdentity(repoRoot, env, tools).env;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// secret-sops.mjs - runtime SOPS adapter and age identity selection.
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { runProgram } from "../api/process/run-program.mjs";
|
|
5
6
|
import { resolveRealTool } from "../api/process/resolve-real-tool.mjs";
|
|
@@ -9,8 +10,8 @@ import { updateKeys as sopsUpdateKeys } from "../api/sops/update-keys.mjs";
|
|
|
9
10
|
import { isLegacyName } from "../lib/sealed-member.mjs";
|
|
10
11
|
import { invocationDir } from "../lib/roots.mjs";
|
|
11
12
|
import { envelopeOf } from "./secret-document.mjs";
|
|
12
|
-
import { configuredRecipients,
|
|
13
|
-
import { outsideRepository
|
|
13
|
+
import { configuredRecipients, decryptionIdentity, secretTools } from "./secret-init.mjs";
|
|
14
|
+
import { outsideRepository } from "./secret-files.mjs";
|
|
14
15
|
import { SecretError, publicStorage } from "./secret-errors.mjs";
|
|
15
16
|
|
|
16
17
|
const sopsInvocation = Object.freeze({ runProgram, resolveRealTool });
|
|
@@ -45,15 +46,27 @@ function recipientsOf(file) {
|
|
|
45
46
|
* The refusal of a decrypt whose selected identity is not one `file` is sealed to: names where the identity came from and the
|
|
46
47
|
* member's recipients, and the two ways out. It carries no value.
|
|
47
48
|
*/
|
|
48
|
-
function identityMismatch(repoRoot, identity, file) {
|
|
49
|
+
function identityMismatch(repoRoot, identity, file, publicRecipient) {
|
|
49
50
|
const recipients = recipientsOf(file);
|
|
51
|
+
const own = publicRecipient ? ` (recipient ${publicRecipient})` : "";
|
|
50
52
|
return new SecretError(
|
|
51
|
-
`the age identity of ${identity.source ?? "the environment"} does not open ${path.basename(file)}, ` +
|
|
52
|
-
`sealed to ${recipients.join(", ") || "another recipient"}. Fix: put
|
|
53
|
-
"or, when no one holds
|
|
53
|
+
`the age identity of ${identity.source ?? "the environment"}${own} does not open ${path.basename(file)}, ` +
|
|
54
|
+
`sealed to ${recipients.join(", ") || "another recipient"}. Fix: put one of those recipients' age secret key in ${path.join(repoRoot, ".claude", "secret.env")} as SOPS_AGE_KEY, ` +
|
|
55
|
+
"or, when no one holds any of them any more, seal the member's keys again (remove it and set them) after .sops.yaml names this identity's recipient",
|
|
54
56
|
);
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Any other refusal of a decrypt: the member, the format it was read as, the recipients it is sealed to, where the age identity
|
|
61
|
+
* came from and (when derived) its recipient, then the refusal itself. Public facts only.
|
|
62
|
+
*/
|
|
63
|
+
function identityRefused(identity, file, format, error) {
|
|
64
|
+
const recipients = recipientsOf(file);
|
|
65
|
+
const own = error.publicRecipient ? `, recipient ${error.publicRecipient}` : "";
|
|
66
|
+
const source = identity.source ? `the age identity of ${identity.source}${own}` : `no age identity (looked in ${identity.looked.join(", ") || "the environment"})`;
|
|
67
|
+
return `${path.basename(file)} (read as ${format ?? "its own format"}, sealed to ${recipients.join(", ") || "no age recipient"}) cannot be opened with ${source}: ${error.message}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
57
70
|
/**
|
|
58
71
|
* The refusal of an operation that must decrypt `file` with no age identity selected: it names the recipient the member is
|
|
59
72
|
* sealed to, every place looked, and the one line that fixes it. It carries no value.
|
|
@@ -70,8 +83,40 @@ function identityMissing(repoRoot, identity, file) {
|
|
|
70
83
|
}
|
|
71
84
|
|
|
72
85
|
/**
|
|
73
|
-
* The
|
|
74
|
-
*
|
|
86
|
+
* The recipient set a seal of `file` writes: the caller's `named` set, else the environment's .sops.yaml rule, else the full set
|
|
87
|
+
* the existing member is sealed to, so no rewrite (set, migrate, split, an edit) drops a recipient such as the deploy server's.
|
|
88
|
+
* `ruled` says whether a rule of the app's .sops.yaml matches the file. Refused, as the rule refuses, when none names one.
|
|
89
|
+
*/
|
|
90
|
+
function sealRecipients(repoRoot, file, named) {
|
|
91
|
+
try {
|
|
92
|
+
const rule = configuredRecipients(repoRoot, file);
|
|
93
|
+
return { recipients: named.length ? named : rule, ruled: true };
|
|
94
|
+
} catch (error) {
|
|
95
|
+
const existing = named.length ? named : fs.existsSync(file) ? recipientsOf(file) : [];
|
|
96
|
+
if (existing.length) return { recipients: existing, ruled: false };
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Runs `seal` with a private sops configuration naming only `recipients` when the app's .sops.yaml has no rule for the member
|
|
103
|
+
* (sops refuses a file no rule matches even with --age); the folder is removed after. Public keys only.
|
|
104
|
+
*/
|
|
105
|
+
function withOwnRule(ruled, recipients, seal) {
|
|
106
|
+
if (ruled) return seal(null);
|
|
107
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "starci-sops-rule-"));
|
|
108
|
+
try {
|
|
109
|
+
const config = path.join(dir, ".sops.yaml");
|
|
110
|
+
fs.writeFileSync(config, `creation_rules:\n - age: ${recipients.join(",")}\n`);
|
|
111
|
+
return seal(config);
|
|
112
|
+
} finally {
|
|
113
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The default sops seam: the runtime's sops api, which finds the binary on this machine; the age identity is decryptionIdentity's
|
|
119
|
+
* (`host` the harness CLI's secret.env, then sops' keys.txt). `identity` says where the age identity
|
|
75
120
|
* was found (`source`, null when nowhere) and where it was looked for; `requireIdentity(file)` refuses, before any value is
|
|
76
121
|
* read, an operation that must decrypt `file` with no identity.
|
|
77
122
|
*/
|
|
@@ -83,7 +128,7 @@ export function defaultSops(
|
|
|
83
128
|
host = null,
|
|
84
129
|
) {
|
|
85
130
|
const { sops: binary } = publicStorage(() => secretTools(env, tools));
|
|
86
|
-
const local =
|
|
131
|
+
const local = decryptionIdentity(repoRoot, env, { tools, host, platform });
|
|
87
132
|
const selectedEnv = local.env;
|
|
88
133
|
const identity = { source: local.source, looked: [...local.looked] };
|
|
89
134
|
const invocation = {
|
|
@@ -109,27 +154,16 @@ export function defaultSops(
|
|
|
109
154
|
);
|
|
110
155
|
checkIdentity(value);
|
|
111
156
|
}
|
|
112
|
-
if (
|
|
113
|
-
!Object.keys(selectedEnv).some((name) => AGE_IDENTITY_NAME.test(name))
|
|
114
|
-
) {
|
|
115
|
-
const keys = secretRoots(env, platform).identity;
|
|
116
|
-
checkIdentity(keys);
|
|
117
|
-
identity.looked.push(keys);
|
|
118
|
-
if (fs.existsSync(keys)) {
|
|
119
|
-
selectedEnv.SOPS_AGE_KEY_FILE = keys;
|
|
120
|
-
identity.source = keys;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
157
|
const requireIdentity = (file) => {
|
|
124
158
|
if (identity.source === null) throw identityMissing(repoRoot, identity, file);
|
|
125
159
|
};
|
|
126
|
-
const refuse = (what, result, file) => {
|
|
160
|
+
const refuse = (what, result, file, format) => {
|
|
127
161
|
if (result.error?.identityRefusal === "identity-not-supplied" && file)
|
|
128
162
|
return identityMissing(repoRoot, identity, file);
|
|
129
163
|
if (result.error?.identityRefusal === "recipient-mismatch" && file)
|
|
130
|
-
return identityMismatch(repoRoot, identity, file);
|
|
164
|
+
return identityMismatch(repoRoot, identity, file, result.error.publicRecipient);
|
|
131
165
|
if (result.error?.identityRefusal)
|
|
132
|
-
return Object.assign(new SecretError(result.error.message), {
|
|
166
|
+
return Object.assign(new SecretError(file ? identityRefused(identity, file, format, result.error) : result.error.message), {
|
|
133
167
|
identityRefusal: result.error.identityRefusal,
|
|
134
168
|
});
|
|
135
169
|
if (result.error?.code === "SOPS_MISSING")
|
|
@@ -157,6 +191,7 @@ export function defaultSops(
|
|
|
157
191
|
`decrypt ${path.basename(file)}; is the age identity installed?`,
|
|
158
192
|
result,
|
|
159
193
|
file,
|
|
194
|
+
format,
|
|
160
195
|
);
|
|
161
196
|
if (exact) return exactMap(result.stdout);
|
|
162
197
|
const map = Object.create(null);
|
|
@@ -193,6 +228,7 @@ export function defaultSops(
|
|
|
193
228
|
`decrypt ${path.basename(file)}; is the age identity installed?`,
|
|
194
229
|
result,
|
|
195
230
|
file,
|
|
231
|
+
types[0],
|
|
196
232
|
);
|
|
197
233
|
return String(result.stdout);
|
|
198
234
|
},
|
|
@@ -207,21 +243,17 @@ export function defaultSops(
|
|
|
207
243
|
{ env: selectedEnv, cwd: repoRoot, invocation },
|
|
208
244
|
);
|
|
209
245
|
if (result.status !== 0)
|
|
210
|
-
throw refuse(`update the keys of ${path.basename(file)}; is the age identity a recipient of it?`, result, file);
|
|
246
|
+
throw refuse(`update the keys of ${path.basename(file)}; is the age identity a recipient of it?`, result, file, isLegacyName(file) ? "dotenv" : "yaml");
|
|
211
247
|
},
|
|
212
248
|
seal(request) {
|
|
213
|
-
const recipients = request.recipients
|
|
214
|
-
|
|
215
|
-
: publicStorage(() =>
|
|
216
|
-
configuredRecipients(repoRoot, request.filenameOverride),
|
|
217
|
-
);
|
|
218
|
-
// Sealing needs only public recipients, so it runs with no identity selected and may name several (an environment's
|
|
249
|
+
const { recipients, ruled } = publicStorage(() => sealRecipients(repoRoot, request.filenameOverride, request.recipients));
|
|
250
|
+
// Sealing needs only public recipients, so it runs with no identity selected and seals to the whole set (an environment's
|
|
219
251
|
// server key and the owner key both).
|
|
220
|
-
const result = sopsSeal(
|
|
252
|
+
const result = withOwnRule(ruled, recipients, (config) => sopsSeal(
|
|
221
253
|
binary,
|
|
222
|
-
{ ...request, recipients },
|
|
254
|
+
{ ...request, recipients, config },
|
|
223
255
|
{ env: withoutIdentity(selectedEnv), cwd: repoRoot, invocation },
|
|
224
|
-
);
|
|
256
|
+
));
|
|
225
257
|
if (result.status !== 0) throw refuse("seal the secret", result);
|
|
226
258
|
return result.stdout;
|
|
227
259
|
},
|
|
@@ -62,13 +62,17 @@ export function isSopsEnvelope(text) {
|
|
|
62
62
|
try { return sopsTree(parseYaml(src)); } catch { return false; }
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
/**
|
|
65
|
+
/** A refused admission, named by its reason. */
|
|
66
66
|
const refusal = (reason) => ({ ok: false, reason });
|
|
67
67
|
|
|
68
|
+
const DOTENV_AGE_FIELD = /^age__list_(0|[1-9]\d{0,2})__map_(recipient|enc)$/u;
|
|
69
|
+
|
|
68
70
|
function dotenvMetadataValue(meta, age, key, value) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
const field = DOTENV_AGE_FIELD.exec(key);
|
|
72
|
+
if (field) {
|
|
73
|
+
const entry = (age[Number(field[1])] ??= {});
|
|
74
|
+
entry[field[2]] = value;
|
|
75
|
+
} else if (key === 'mac_only_encrypted') {
|
|
72
76
|
if (value !== 'false') return 'partial-mac';
|
|
73
77
|
meta[key] = false;
|
|
74
78
|
} else if (key.includes('__')) return 'unsupported-key-group';
|
|
@@ -88,13 +92,14 @@ function readDotenvMetadata(text) {
|
|
|
88
92
|
pairs.set(name, line.slice(at + 1).replaceAll(String.raw`\n`, '\n'));
|
|
89
93
|
}
|
|
90
94
|
const meta = {};
|
|
91
|
-
const age =
|
|
95
|
+
const age = [];
|
|
92
96
|
for (const [name, value] of pairs) {
|
|
93
97
|
if (!name.startsWith('sops_')) continue;
|
|
94
98
|
const reason = dotenvMetadataValue(meta, age, name.slice('sops_'.length), value);
|
|
95
99
|
if (reason) return { refusal: reason };
|
|
96
100
|
}
|
|
97
|
-
|
|
101
|
+
// A gap in the list indices is an entry sops never wrote: refused as an empty entry, never skipped.
|
|
102
|
+
meta.age = Array.from(age, (entry) => entry ?? null);
|
|
98
103
|
return { meta };
|
|
99
104
|
}
|
|
100
105
|
|
|
@@ -142,15 +147,35 @@ function validateAgeMetadata(meta, text, recipient) {
|
|
|
142
147
|
const fields = new Set([...providers, 'age', 'lastmodified', 'mac', 'version', 'unencrypted_suffix', 'encrypted_suffix', 'unencrypted_regex', 'encrypted_regex', 'unencrypted_comment_regex', 'encrypted_comment_regex', 'mac_only_encrypted']);
|
|
143
148
|
if (Object.keys(meta).some(name => !fields.has(name))) return refusal('unknown-metadata');
|
|
144
149
|
if (Object.hasOwn(meta, 'mac_only_encrypted') && meta.mac_only_encrypted !== false) return refusal('partial-mac');
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (!
|
|
148
|
-
if (typeof entry.recipient !== 'string' || entry.recipient !== recipient) return refusal('recipient-mismatch');
|
|
149
|
-
if (!hasValidAgePayload(entry)) return refusal('invalid-age-entry');
|
|
150
|
+
const set = ageSet(meta.age);
|
|
151
|
+
if (!set.ok) return set;
|
|
152
|
+
if (!set.recipients.includes(recipient)) return refusal('recipient-mismatch');
|
|
150
153
|
if (!hasValidEnvelopeVersion(meta)) return refusal('invalid-envelope');
|
|
151
154
|
return { ok: true };
|
|
152
155
|
}
|
|
153
156
|
|
|
157
|
+
const AGE_RECIPIENT = /^age1[ac-hj-np-z02-9]+$/u;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The age set of an envelope: one or more entries (the owner key and an environment's server key are the standard pair), each a
|
|
161
|
+
* well-formed recipient with its wrapped data key; an empty entry or a recipient named twice is refused.
|
|
162
|
+
*/
|
|
163
|
+
function ageSet(entries) {
|
|
164
|
+
if (!Array.isArray(entries) || entries.length === 0) return refusal('unsupported-age-set');
|
|
165
|
+
const recipients = [];
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
if (!isAgeEntry(entry) || typeof entry.recipient !== 'string' || !AGE_RECIPIENT.test(entry.recipient) || !hasValidAgePayload(entry))
|
|
168
|
+
return refusal('invalid-age-entry');
|
|
169
|
+
if (recipients.includes(entry.recipient)) return refusal('duplicate-age-recipient');
|
|
170
|
+
recipients.push(entry.recipient);
|
|
171
|
+
}
|
|
172
|
+
return { ok: true, recipients };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Admit one flat age envelope for the native selected-identity invocation when `recipient` (the selected identity's public key) is
|
|
177
|
+
* one of its age set. Shape admission, never cryptographic proof.
|
|
178
|
+
*/
|
|
154
179
|
export function selectedAgeEnvelope(text, format, recipient) {
|
|
155
180
|
if (!['binary', 'json', 'yaml', 'dotenv'].includes(format)) return refusal('unsupported-format');
|
|
156
181
|
const parsed = readAgeMetadata(text, format);
|
package/scaffold/add-compose.mjs
CHANGED
|
@@ -7,7 +7,10 @@ import { ScaffoldError } from './service.mjs';
|
|
|
7
7
|
export function addModuleImport(lines, { importLine, entry, relative, noun }) {
|
|
8
8
|
const active = compositionCode(lines.join('\n')).split('\n');
|
|
9
9
|
const imported = active.some(line => line.replace(/;\s*$/, '') === importLine);
|
|
10
|
+
// An area group spread once is registered whatever its argument (the lite root passes `tokens.verify` to domainModules).
|
|
11
|
+
const group = /^\.\.\.(\w+Modules)\(/.exec(entry)?.[1];
|
|
10
12
|
const registered = active.some(line => line.trim() === `${entry},` ||
|
|
13
|
+
(group !== undefined && line.includes(`...${group}(`)) ||
|
|
11
14
|
(/\bimports:\s*\[/.test(line) && !line.trim().startsWith('//') && line.replace(/\s+/g, '').includes(entry.replace(/\s+/g, ''))));
|
|
12
15
|
const lastImport = active.reduce((last, line, index) => line.startsWith('import ') ? index : last, -1);
|
|
13
16
|
const importsLine = line => /\bimports:\s*\[/.test(line) && !line.trim().startsWith('//');
|
|
@@ -13,6 +13,10 @@ const PLACEHOLDER = /@@([A-Za-z][A-Za-z0-9]*)@@/g;
|
|
|
13
13
|
const timestampOf = (now) =>
|
|
14
14
|
new Date(now()).toISOString().replace(/\D/g, "").slice(0, 14);
|
|
15
15
|
const upperOf = (name) => name.replaceAll("-", "_").toUpperCase();
|
|
16
|
+
const camelOf = (name) => {
|
|
17
|
+
const pascal = pascalOf(name);
|
|
18
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
19
|
+
};
|
|
16
20
|
const snakeOf = (name) => name.replaceAll("-", "_");
|
|
17
21
|
|
|
18
22
|
const template = (relative, values) => {
|
|
@@ -72,8 +76,6 @@ const databasePatches = (root) => {
|
|
|
72
76
|
"lite add webhook needs the scaffolded platform/database capability",
|
|
73
77
|
);
|
|
74
78
|
let index = fs.readFileSync(indexFile, "utf8").replace(/\r\n/g, "\n");
|
|
75
|
-
if (!index.includes('export { recordWebhookDelivery, webhookReceiptInsert } from "./webhook-inbox.client"'))
|
|
76
|
-
index = `${index.trimEnd()}\nexport { recordWebhookDelivery, webhookReceiptInsert } from "./webhook-inbox.client"\n`;
|
|
77
79
|
if (!index.split("\n").some((line) => line.startsWith("export { ") && line.includes("DatabaseErrorCode") && line.endsWith(' } from "./errors/database.error"')))
|
|
78
80
|
index = `${index.trimEnd()}\nexport { DatabaseError, DatabaseErrorCode } from "./errors/database.error"\n`;
|
|
79
81
|
let error = withSqlIdentifierError(fs.readFileSync(errorFile, "utf8").replace(/\r\n/g, "\n"));
|
|
@@ -107,7 +109,15 @@ const handoffPlatformFiles = (root, values) => {
|
|
|
107
109
|
const planned = [];
|
|
108
110
|
for (const capability of ["inbox", "idempotency", "event-bus"]) {
|
|
109
111
|
const base = `be/src/modules/platform/${capability}`;
|
|
110
|
-
if (fs.existsSync(path.join(root, base, "index.ts")))
|
|
112
|
+
if (fs.existsSync(path.join(root, base, "index.ts"))) {
|
|
113
|
+
// The provider intake claims through the EntityInbox port; an inbox written before it cannot serve a new provider.
|
|
114
|
+
if (capability === "inbox" && !fs.readFileSync(path.join(root, base, "index.ts"), "utf8").includes("InjectEntityInbox"))
|
|
115
|
+
throw new ScaffoldError(
|
|
116
|
+
"HFS_ADD_INBOX_ENTITY_PORT",
|
|
117
|
+
`${base} predates the EntityInbox port (InjectEntityInbox, claimRow) the ${values.provider} intake claims through; bring it to the inbox of this hfs first`,
|
|
118
|
+
);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
111
121
|
const folder = path.join(TEMPLATES_DIR, "be", "inbox", "platform", capability);
|
|
112
122
|
for (const file of filesBelow(folder)) {
|
|
113
123
|
const relative = path.relative(folder, file).replaceAll("\\", "/");
|
|
@@ -149,6 +159,28 @@ const eventFiles = (root, provider, values) => {
|
|
|
149
159
|
return { planned, patches };
|
|
150
160
|
};
|
|
151
161
|
|
|
162
|
+
/** The lite entity list (BE-PERSISTENCE-3), where the provider inbox's `<provider>InboxEntities` is spread once. */
|
|
163
|
+
const PRIMARY_ENTITIES_FILE = ["be", "src", "modules", "platform", "database", "entities.ts"];
|
|
164
|
+
const PRIMARY_ENTITIES = /(export const PRIMARY_ENTITIES[^=]*=\s*\[)([\s\S]*?)(\])/;
|
|
165
|
+
|
|
166
|
+
/** entities.ts with the receipt entity list imported from the provider inbox barrel and spread into PRIMARY_ENTITIES. */
|
|
167
|
+
const primaryEntitiesPatch = (root, provider, list) => {
|
|
168
|
+
const file = path.join(root, ...PRIMARY_ENTITIES_FILE);
|
|
169
|
+
const relative = PRIMARY_ENTITIES_FILE.join("/");
|
|
170
|
+
if (!fs.existsSync(file))
|
|
171
|
+
throw new ScaffoldError("HFS_ADD_INBOX_DATABASE", `lite add webhook names ${list} in PRIMARY_ENTITIES of ${relative}, which is missing`);
|
|
172
|
+
const text = fs.readFileSync(file, "utf8").replace(/\r\n/g, "\n");
|
|
173
|
+
if (!PRIMARY_ENTITIES.test(text))
|
|
174
|
+
throw new ScaffoldError("HFS_ADD_INBOX_DATABASE", `lite add webhook names ${list} in PRIMARY_ENTITIES of ${relative}, which declares no PRIMARY_ENTITIES array`);
|
|
175
|
+
if (new RegExp(String.raw`\.\.\.${list}\b`).test(text)) return { file, text };
|
|
176
|
+
const withList = text.replace(PRIMARY_ENTITIES, (all, head, items, tail) =>
|
|
177
|
+
`${head}${[...items.split(",").map((item) => item.trim()).filter(Boolean), `...${list}`].join(", ")}${tail}`);
|
|
178
|
+
const lines = withList.split("\n");
|
|
179
|
+
const lastImport = lines.reduce((at, line, index) => (line.startsWith("import ") ? index : at), -1);
|
|
180
|
+
lines.splice(lastImport + 1, 0, `import { ${list} } from "@modules/domain/${provider}-inbox"`);
|
|
181
|
+
return { file, text: lines.join("\n") };
|
|
182
|
+
};
|
|
183
|
+
|
|
152
184
|
const eventPatternPatch = (root) => {
|
|
153
185
|
const file = path.join(root, "hfs.json");
|
|
154
186
|
const declaration = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
@@ -190,6 +222,7 @@ export function addLiteWebhookInbox({
|
|
|
190
222
|
...values,
|
|
191
223
|
provider,
|
|
192
224
|
Provider,
|
|
225
|
+
providerCamel: camelOf(provider),
|
|
193
226
|
providerUpper: upperOf(provider),
|
|
194
227
|
table,
|
|
195
228
|
};
|
|
@@ -221,6 +254,14 @@ export function addLiteWebhookInbox({
|
|
|
221
254
|
relative: `${base}/${provider}-inbox.service.spec.ts`,
|
|
222
255
|
text: template("service.spec.ts.tpl", renderValues),
|
|
223
256
|
},
|
|
257
|
+
{
|
|
258
|
+
relative: `${base}/persistence/entities/${provider}-inbox.entity.ts`,
|
|
259
|
+
text: template("entity.ts.tpl", renderValues),
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
relative: `${base}/persistence/entities/index.ts`,
|
|
263
|
+
text: template("entities.index.ts.tpl", renderValues),
|
|
264
|
+
},
|
|
224
265
|
{
|
|
225
266
|
relative: `be/src/modules/events/${provider}/${provider}-delivery-recorded.event.ts`,
|
|
226
267
|
text: template("delivery-recorded.event.ts.tpl", renderValues),
|
|
@@ -242,26 +283,6 @@ export function addLiteWebhookInbox({
|
|
|
242
283
|
text: render(fs.readFileSync(path.join(TEMPLATES_DIR, "be", "skeleton-lite", "src", "modules", "platform", "database", file), "utf8"), {}),
|
|
243
284
|
});
|
|
244
285
|
}
|
|
245
|
-
const databaseClient = path.join(
|
|
246
|
-
root,
|
|
247
|
-
"be",
|
|
248
|
-
"src",
|
|
249
|
-
"modules",
|
|
250
|
-
"platform",
|
|
251
|
-
"database",
|
|
252
|
-
"webhook-inbox.client.ts",
|
|
253
|
-
);
|
|
254
|
-
if (!fs.existsSync(databaseClient))
|
|
255
|
-
planned.push(
|
|
256
|
-
{
|
|
257
|
-
relative: "be/src/modules/platform/database/webhook-inbox.client.ts",
|
|
258
|
-
text: template("database-client.ts.tpl", renderValues),
|
|
259
|
-
},
|
|
260
|
-
{
|
|
261
|
-
relative: "be/src/modules/platform/database/webhook-inbox.client.spec.ts",
|
|
262
|
-
text: template("database-client.spec.ts.tpl", renderValues),
|
|
263
|
-
},
|
|
264
|
-
);
|
|
265
286
|
const clashes = planned
|
|
266
287
|
.filter((entry) => fs.existsSync(path.join(root, ...entry.relative.split("/"))))
|
|
267
288
|
.map((entry) => entry.relative);
|
|
@@ -272,6 +293,7 @@ export function addLiteWebhookInbox({
|
|
|
272
293
|
);
|
|
273
294
|
const patches = [
|
|
274
295
|
...databasePatches(root),
|
|
296
|
+
primaryEntitiesPatch(root, provider, `${renderValues.providerCamel}InboxEntities`),
|
|
275
297
|
...events.patches,
|
|
276
298
|
eventPatternPatch(root),
|
|
277
299
|
];
|
|
@@ -108,6 +108,19 @@ const REGISTRATIONS = Object.freeze({
|
|
|
108
108
|
source: "./webhook-signature.service",
|
|
109
109
|
names: ["WebhookSignatureService"],
|
|
110
110
|
},
|
|
111
|
+
{
|
|
112
|
+
// The webhook door types its request with the correlation the logging middleware stamps on it.
|
|
113
|
+
file: "be/src/modules/platform/logging/index.ts",
|
|
114
|
+
source: "./correlation.contracts",
|
|
115
|
+
names: ["CorrelationRequest"],
|
|
116
|
+
type: true,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
// The lite event bus refuses an envelope whose correlation id is not one the logging capability issues.
|
|
120
|
+
file: "be/src/modules/platform/logging/index.ts",
|
|
121
|
+
source: "./correlation.policy",
|
|
122
|
+
names: ["isCorrelationId"],
|
|
123
|
+
},
|
|
111
124
|
],
|
|
112
125
|
"webhook-full": [
|
|
113
126
|
{
|
package/src/main.mjs
CHANGED
|
@@ -116,11 +116,11 @@ starci app db migrate [--execute] [--url-env <NAME>] [--json] [--cwd <dir>]
|
|
|
116
116
|
starci app db post-cutover [--execute] [--green-at <ISO> | --owner-confirms-old-app-gone] [--url-env <NAME>] [--json] [--cwd <dir>]
|
|
117
117
|
starci app db drift [--url-env <NAME>] [--json] [--cwd <dir>]
|
|
118
118
|
starci app release gate [--execute] [--source <ref>] [--tag-prefix <prefix>] [--backfill <tag>] [--json] [--cwd <dir>]
|
|
119
|
-
starci app release push [--execute] [--remote <name>] [--urgent --reason <text>] [--json] [--cwd <dir>]
|
|
119
|
+
starci app release push [--execute] [--remote <name>] [--urgent --reason <text> [--lint-baseline <tag>]] [--json] [--cwd <dir>]
|
|
120
120
|
starci app upgrade [--plan] [--cwd <dir>]
|
|
121
121
|
starci app stack <up|down|status> [--stack <dir>] [--services <list>] [--k3d] [--force] [--json] [--cwd <dir>]
|
|
122
122
|
starci app stack <prepare|doctor|connect|disconnect|build-args|render|deploy|converge|verify|rollback|prune|backup|jobs> --env <env> [--execute] [--json] [--cwd <dir>]
|
|
123
|
-
starci app stack deploy --env <env> --source-sha <sha> --urgent [--db-url-env <NAME>] [--backup-out <dir>] [--execute] [--json] [--cwd <dir>]
|
|
123
|
+
starci app stack deploy --env <env> --source-sha <sha> --urgent [--db-url-env <NAME>] [--backup-out <dir>] [--first-start --db-service <service>] [--execute] [--json] [--cwd <dir>]
|
|
124
124
|
starci app explain <path> [--cwd <dir>] [--json]
|
|
125
125
|
starci app emit [--cwd <dir>]
|
|
126
126
|
starci app new service <dir> <name> [--inject <binding>...] [--cwd <dir>]
|
|
@@ -435,8 +435,8 @@ function declaredEdition(cwd) {
|
|
|
435
435
|
* `starci app stack`: the deploy actions (prepare, doctor, render, deploy, converge, verify, rollback, prune, backup, jobs, ...) are the Swarm
|
|
436
436
|
* tooling of stack/cli.mjs; up, down and status are the full edition's test-world infrastructure.
|
|
437
437
|
*/
|
|
438
|
-
async function stackAppMain(argv, { cwd, edition, stackMain, stdout: out, stderr: err, env, sops, stackIo }) {
|
|
439
|
-
if (STACK_ACTIONS.includes(argv[0])) return deployStackMain(argv, { ...stackIo, cwd, stdout: out, stderr: err, env, ...(sops ? { sops } : {}) });
|
|
438
|
+
async function stackAppMain(argv, { cwd, edition, stackMain, stdout: out, stderr: err, env, sops, hostIdentity, stackIo }) {
|
|
439
|
+
if (STACK_ACTIONS.includes(argv[0])) return deployStackMain(argv, { ...stackIo, cwd, stdout: out, stderr: err, env, hostIdentity, ...(sops ? { sops } : {}) });
|
|
440
440
|
if ((edition ?? declaredEdition(cwd)) === 'lite') throw new Error('starci app stack up|down|status manage the local test world of the full edition; a lite app starts its local database with npm run db:start');
|
|
441
441
|
const run = stackMain ?? await installedStackMain(cwd);
|
|
442
442
|
return run(argv, { cwd, out, err });
|
|
@@ -462,7 +462,7 @@ function errorMessage(error) {
|
|
|
462
462
|
* `harness` ({ version }) is the CLI that runs this package in-process: its version is the starci-harness an app is judged and
|
|
463
463
|
* scaffolded at (runtime/scripts/hfs/canon-pins.mjs). `run` injects the database runner, `runLinter` the linter transport, `checkApp`
|
|
464
464
|
* the HFS check and `stackIo` Swarm tools; production defaults execute every check. `hostIdentity` ({ label, read }) is the
|
|
465
|
-
* harness CLI's own secret.env SOPS_AGE_KEY, which
|
|
465
|
+
* harness CLI's own secret.env SOPS_AGE_KEY, which every verb that opens a sealed member (secret, stack, release, db) reads last for the age identity (decryptionIdentity); `prompt` replaces its masked prompt.
|
|
466
466
|
*/
|
|
467
467
|
export async function main(argv, {
|
|
468
468
|
cwd: baseCwd = process.cwd(), harness, stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), presets, prettier, lock, run, stackMain,
|
package/stack/age-install.mjs
CHANGED
|
@@ -17,10 +17,18 @@ const STATIC_INSTALL = [
|
|
|
17
17
|
`echo "${AGE_SHA256} $tmp/age.tgz" | sha256sum -c -`,
|
|
18
18
|
'tar -xzf "$tmp/age.tgz" -C "$tmp"',
|
|
19
19
|
'sudo -n install -m 0755 "$tmp/age/age" "$tmp/age/age-keygen" /usr/local/bin/',
|
|
20
|
+
// sudo's secure_path (AlmaLinux 10, RHEL) leaves /usr/local/bin out: a link in /usr/bin makes both tools visible to the deploy
|
|
21
|
+
// user and to root alike; an existing /usr/bin tool (a packaged age) is kept.
|
|
22
|
+
'for b in age age-keygen; do if [ ! -e "/usr/bin/$b" ]; then sudo -n ln -s "/usr/local/bin/$b" "/usr/bin/$b"; fi; done',
|
|
20
23
|
].join('; ');
|
|
21
24
|
|
|
22
25
|
const packageManager = (run) => run('for m in dnf yum apk apt-get; do command -v $m >/dev/null 2>&1 && echo $m && break; done').stdout.trim();
|
|
23
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Both tools, as the deploy user and, where passwordless sudo exists, as root: an install the root PATH cannot see is not taken
|
|
28
|
+
* for present.
|
|
29
|
+
*/
|
|
30
|
+
const PRESENT = 'command -v age >/dev/null 2>&1 && command -v age-keygen >/dev/null 2>&1 && { [ "$(id -u)" -eq 0 ] || ! sudo -n true 2>/dev/null || sudo -n sh -c \'command -v age >/dev/null 2>&1 && command -v age-keygen >/dev/null 2>&1\'; }';
|
|
31
|
+
const present = (run) => run(PRESENT).status === 0;
|
|
24
32
|
|
|
25
33
|
/** Installs age when it is missing; reports through `report`. */
|
|
26
34
|
export function installAge(run, report) {
|
|
@@ -33,6 +41,6 @@ export function installAge(run, report) {
|
|
|
33
41
|
failure = `${manager} could not install age: ${firstLine(packaged.stderr)}`;
|
|
34
42
|
}
|
|
35
43
|
const fetched = run(STATIC_INSTALL);
|
|
36
|
-
if (fetched.status === 0 && present(run)) report.note(`installed the official static age ${AGE_VERSION} (sha256 verified) into /usr/local/bin; ${failure}`);
|
|
44
|
+
if (fetched.status === 0 && present(run)) report.note(`installed the official static age ${AGE_VERSION} (sha256 verified) into /usr/local/bin, linked from /usr/bin; ${failure}`);
|
|
37
45
|
else report.bad(`cannot install age (${failure}); the static install failed too: ${firstLine(fetched.stderr)} (needs passwordless sudo once, or install age by hand)`);
|
|
38
46
|
}
|
package/stack/cli.mjs
CHANGED
|
@@ -14,8 +14,8 @@ const DOCKER_ACTIONS = new Set(['deploy', 'accept', 'converge', 'verify', 'rollb
|
|
|
14
14
|
|
|
15
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']);
|
|
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', '--urgent']);
|
|
18
|
-
const VALUE = new Set(['--env', '--image', '--source-sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project', '--target-binding', '--db-url-env', '--backup-out']);
|
|
17
|
+
const BOOLEAN = new Set(['--execute', '--json', '--swarm-state', '--init-swarm', '--owner-approved', '--fix-live-restore', '--skip-public', '--skip-internal', '--allow-unpinned', '--push', '--urgent', '--first-start']);
|
|
18
|
+
const VALUE = new Set(['--env', '--image', '--source-sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project', '--target-binding', '--db-url-env', '--backup-out', '--db-service']);
|
|
19
19
|
const REPEAT = new Set(['--image', '--keep', '--extra-compose', '--project']);
|
|
20
20
|
|
|
21
21
|
/** Splits argv into { flags, positional }; an unknown flag or a flag without its value is a refusal. */
|
|
@@ -72,6 +72,7 @@ const OPTIONS = {
|
|
|
72
72
|
images: parseImages(flags.image), sourceSha: flags['source-sha'], execute: flags.execute === true,
|
|
73
73
|
out: flags.out ? path.resolve(cwd, flags.out) : null, timeoutSeconds: number(flags, 'timeout'), retrySeconds: number(flags, 'retry'), skipPublic: flags['skip-public'] === true,
|
|
74
74
|
urgent: flags.urgent === true, dbUrlEnv: flags['db-url-env'], backupOut: flags['backup-out'] ? path.resolve(cwd, flags['backup-out']) : undefined,
|
|
75
|
+
firstStart: flags['first-start'] === true, dbService: flags['db-service'],
|
|
75
76
|
}),
|
|
76
77
|
converge: ({ flags }) => ({ since: flags.since, timeoutSeconds: number(flags, 'timeout') }),
|
|
77
78
|
verify: ({ flags }) => ({ retrySeconds: number(flags, 'retry'), skipPublic: flags['skip-public'] === true, skipInternal: flags['skip-internal'] === true }),
|
|
@@ -126,7 +127,8 @@ export async function stackMain(argv, io = {}) {
|
|
|
126
127
|
if (binding && (!['deploy', 'verify'].includes(action) || flags['ssh-cmd'] || flags['skip-public'] || flags['skip-internal']))
|
|
127
128
|
throw refusal('STACK_USAGE', 'the bound native consumer supports only complete deploy or verify');
|
|
128
129
|
const config = bindStackTarget(ctx, loadDeployConfig({ repoRoot: ctx.cwd, env: flags.env ?? 'production' }), binding);
|
|
129
|
-
if (action !== 'deploy' && (flags.urgent || flags['db-url-env'] || flags['backup-out'])) throw refusal('STACK_USAGE', '--urgent, --db-url-env
|
|
130
|
+
if (action !== 'deploy' && (flags.urgent || flags['db-url-env'] || flags['backup-out'] || flags['first-start'] || flags['db-service'])) throw refusal('STACK_USAGE', '--urgent, --db-url-env, --backup-out, --first-start and --db-service belong to deploy');
|
|
131
|
+
if (!flags.urgent && (flags['first-start'] || flags['db-service'])) throw refusal('STACK_USAGE', '--first-start and --db-service are the first start of an empty database under the urgent floor: add --urgent');
|
|
130
132
|
if (action === 'deploy' && !flags.urgent && (flags['db-url-env'] || flags['backup-out'])) throw refusal('STACK_USAGE', '--db-url-env and --backup-out choose the urgent floor\'s database and backup folder: add --urgent');
|
|
131
133
|
const options = OPTIONS[action]({ flags, positional, cwd: ctx.cwd });
|
|
132
134
|
const handler = await handlerOf(action, io.importer ?? ((file) => import(file)));
|