@starci/hfs 4.2.17 → 4.3.0
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 +17 -0
- package/README.md +7 -0
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/runtime/knowledge/hfs/rules.yaml +41 -13
- package/runtime/knowledge/hfs/slots.yaml +99 -43
- package/runtime/knowledge/patterns/be/api.yaml +64 -1
- package/runtime/knowledge/patterns/be/cli.yaml +23 -5
- package/runtime/knowledge/patterns/be/webhooks.yaml +187 -32
- package/runtime/knowledge/patterns/fe/folder.yaml +3 -0
- package/runtime/modules/kernel/failure-codes.yaml +15 -4
- package/runtime/scripts/api/git/cat-file.mjs +5 -0
- package/runtime/scripts/api/sops/resolve-sops.mjs +7 -0
- package/runtime/scripts/api/sops/seal.mjs +1 -1
- package/runtime/scripts/hfs/architecture/backend-app-files.mjs +110 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +20 -3
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
- package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
- package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
- package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
- package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
- package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +14 -9
- package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
- package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +21 -12
- package/runtime/scripts/hfs/architecture/register-once.mjs +13 -5
- package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
- package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +3 -6
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
- package/runtime/scripts/hfs/architecture/tiers.mjs +8 -1
- package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +1 -11
- package/runtime/scripts/hfs/check.mjs +18 -26
- package/runtime/scripts/hfs/manifest-shape.mjs +27 -2
- package/runtime/scripts/hfs/path-findings.mjs +32 -2
- package/runtime/scripts/hfs/rules/database-migrations.mjs +36 -35
- package/runtime/scripts/hfs/rules/database.mjs +2 -2
- package/runtime/scripts/hfs/rules/edition.mjs +5 -5
- package/runtime/scripts/hfs/rules/proof-commands.mjs +2 -0
- package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +128 -0
- package/runtime/scripts/hfs/rules/stacks.mjs +13 -8
- package/runtime/scripts/hfs/secret-document.mjs +97 -0
- package/runtime/scripts/hfs/secret-errors.mjs +20 -0
- package/runtime/scripts/hfs/secret-files.mjs +124 -0
- package/runtime/scripts/hfs/secret-init.mjs +192 -0
- package/runtime/scripts/hfs/secret-output.mjs +100 -0
- package/runtime/scripts/hfs/secret-sops.mjs +98 -0
- package/runtime/scripts/hfs/secret-store.mjs +161 -0
- package/runtime/scripts/hfs/secret.mjs +135 -195
- package/runtime/scripts/hfs/slot-imports.mjs +3 -1
- package/runtime/scripts/hfs/slot-required.mjs +13 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
- package/runtime/scripts/hfs/tree-findings.mjs +22 -0
- package/runtime/scripts/lib/roots.mjs +59 -0
- package/runtime/scripts/lib/swarm-stack.mjs +139 -0
- package/runtime/scripts/lib/ts-analysis.mjs +20 -0
- package/scaffold/add-cli-lite.mjs +10 -11
- package/scaffold/add-inbox-lite.mjs +15 -84
- package/scaffold/add-plan.mjs +183 -0
- package/scaffold/add-table.mjs +44 -5
- package/scaffold/add.mjs +33 -166
- package/scaffold/app.mjs +5 -5
- package/scaffold/image.mjs +2 -2
- package/src/main.mjs +12 -6
- package/sync/index.mjs +23 -16
- package/templates/app/gitignore +1 -0
- package/templates/app/gitignore-lite +1 -0
- package/templates/app/hooks/husky/pre-commit +7 -12
- package/templates/app/skeleton/.sops.yaml +1 -1
- package/templates/app/skeleton-lite/.sops.yaml +1 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +16 -0
- package/templates/app/skeleton-lite/.starciwork/workspace.yaml +9 -0
- package/templates/app/skeleton-lite/README.md +4 -2
- package/templates/app/starciwork.gitignore-lite +6 -0
- package/templates/be/inbox/database-client.spec.ts.tpl +15 -20
- package/templates/be/inbox/database-client.ts.tpl +7 -22
- package/templates/be/inbox/service.spec.ts.tpl +16 -20
- package/templates/be/inbox/service.ts.tpl +6 -10
- package/templates/be/inbox/sql.ts.tpl +2 -2
- package/templates/be/inbox/webhook-door.spec.ts.tpl +3 -3
- package/templates/be/inbox/webhook-door.ts.tpl +4 -3
- package/templates/be/patterns/webhooks/webhook.door.spec.ts.tpl +3 -3
- package/templates/be/patterns/webhooks/webhook.door.ts.tpl +3 -3
- package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +21 -0
- package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +1 -1
- package/templates/be/skeleton/apps/__app__/src/app.module.ts +7 -39
- package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +42 -0
- package/templates/be/skeleton/apps/__app__/src/main.ts +3 -9
- package/templates/be/skeleton/apps/cli/src/cli.env.ts +13 -0
- package/templates/be/skeleton/apps/cli/src/cli.options.ts +6 -8
- package/templates/be/skeleton/apps/cli/src/main.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +6 -6
- package/templates/be/skeleton-lite/apps/api/src/api.env.ts +25 -0
- package/templates/be/skeleton-lite/apps/api/src/api.options.ts +1 -1
- package/templates/be/skeleton-lite/apps/api/src/app.module.ts +6 -42
- package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +45 -0
- package/templates/be/skeleton-lite/apps/api/src/main.ts +4 -11
- package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +17 -0
- package/upgrade/file-state.mjs +68 -0
- package/upgrade/index.mjs +7 -56
- package/runtime/scripts/api/git/log.mjs +0 -5
- package/templates/app/skeleton-lite/.starciwork/shell/index.yaml +0 -55
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// secret-store.mjs - sealed secret paths and atomic named-value updates.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { claimFile } from "../api/fs/claim-file.mjs";
|
|
6
|
+
import { isSopsEnvelope } from "../lib/sops-envelope.mjs";
|
|
7
|
+
import { byCodeUnit } from "../lib/list.mjs";
|
|
8
|
+
import {
|
|
9
|
+
envelopeOf,
|
|
10
|
+
plaintextOf,
|
|
11
|
+
validateKey,
|
|
12
|
+
validateMap,
|
|
13
|
+
} from "./secret-document.mjs";
|
|
14
|
+
import { defaultSops } from "./secret-sops.mjs";
|
|
15
|
+
import { SecretError } from "./secret-errors.mjs";
|
|
16
|
+
|
|
17
|
+
const SLUG = /^[a-z0-9][a-z0-9-]*$/u;
|
|
18
|
+
|
|
19
|
+
/** The directory of the sealed secrets of the app at `repoRoot` for `env`; the one env that has secrets when none is named. */
|
|
20
|
+
export function secretsDirectory(repoRoot, env) {
|
|
21
|
+
const stacks = path.join(repoRoot, ".starcistacks");
|
|
22
|
+
if (env) {
|
|
23
|
+
if (!/^[a-z0-9][a-z0-9-]*$/u.test(env))
|
|
24
|
+
throw new SecretError(`--env ${env} is not an environment name`);
|
|
25
|
+
return path.join(stacks, env, "secrets");
|
|
26
|
+
}
|
|
27
|
+
if (!fs.existsSync(stacks))
|
|
28
|
+
throw new SecretError(
|
|
29
|
+
"this app has no .starcistacks; run starci app secret at the app root, or pass --cwd",
|
|
30
|
+
);
|
|
31
|
+
const withSecrets = fs
|
|
32
|
+
.readdirSync(stacks, { withFileTypes: true })
|
|
33
|
+
.filter(
|
|
34
|
+
(entry) =>
|
|
35
|
+
entry.isDirectory() &&
|
|
36
|
+
fs.existsSync(path.join(stacks, entry.name, "secrets")),
|
|
37
|
+
)
|
|
38
|
+
.map((entry) => entry.name);
|
|
39
|
+
if (withSecrets.length === 1)
|
|
40
|
+
return path.join(stacks, withSecrets[0], "secrets");
|
|
41
|
+
throw new SecretError(
|
|
42
|
+
withSecrets.length === 0
|
|
43
|
+
? "no environment of .starcistacks has a secrets folder; pass --env to create the first secret"
|
|
44
|
+
: `more than one environment has secrets (${withSecrets.join(", ")}); pass --env`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const fileOf = (directory, slug) => {
|
|
49
|
+
if (!SLUG.test(slug ?? ""))
|
|
50
|
+
throw new SecretError(
|
|
51
|
+
`${slug === undefined ? "a secret name" : slug} is not a secret slug: lowercase letters, digits and dashes`,
|
|
52
|
+
);
|
|
53
|
+
return path.join(directory, `${slug}.enc`);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Seal `key = value` into the secret `slug`: an existing document keeps its other keys and its recipients. */
|
|
57
|
+
export function writeSecret({ file, values, age, sops }) {
|
|
58
|
+
for (const key of Object.keys(values)) validateKey(key);
|
|
59
|
+
const held = claimFile(path.resolve(file));
|
|
60
|
+
if (!held.ok)
|
|
61
|
+
throw new SecretError(
|
|
62
|
+
"the sealed secret is being updated; retry after that write settles",
|
|
63
|
+
);
|
|
64
|
+
try {
|
|
65
|
+
const format = "dotenv";
|
|
66
|
+
let map = Object.create(null);
|
|
67
|
+
let recipients = age;
|
|
68
|
+
if (fs.existsSync(file)) {
|
|
69
|
+
const text = fs.readFileSync(file, "utf8");
|
|
70
|
+
const envelope = envelopeOf(text);
|
|
71
|
+
map = sops.decrypt(file, envelope.format);
|
|
72
|
+
if (!age.length) recipients = envelope.recipients;
|
|
73
|
+
}
|
|
74
|
+
validateMap(map);
|
|
75
|
+
map = { ...map, ...values };
|
|
76
|
+
const sealed = sops.seal({
|
|
77
|
+
inputType: format,
|
|
78
|
+
plaintext: plaintextOf(format, map),
|
|
79
|
+
recipients,
|
|
80
|
+
filenameOverride: file,
|
|
81
|
+
});
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = envelopeOf(sealed);
|
|
85
|
+
} catch {
|
|
86
|
+
throw new SecretError("sops returned an invalid sealed document");
|
|
87
|
+
}
|
|
88
|
+
if (
|
|
89
|
+
!isSopsEnvelope(sealed) ||
|
|
90
|
+
parsed.keys.length !== Object.keys(map).length ||
|
|
91
|
+
!parsed.keys.every((key) => Object.hasOwn(map, key))
|
|
92
|
+
)
|
|
93
|
+
throw new SecretError("sops returned an invalid sealed document");
|
|
94
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
95
|
+
const temporary = path.join(
|
|
96
|
+
path.dirname(file),
|
|
97
|
+
`.${path.basename(file)}-${randomUUID()}.tmp`,
|
|
98
|
+
);
|
|
99
|
+
try {
|
|
100
|
+
fs.writeFileSync(temporary, sealed, {
|
|
101
|
+
flag: "wx",
|
|
102
|
+
mode: 0o600,
|
|
103
|
+
flush: true,
|
|
104
|
+
});
|
|
105
|
+
fs.renameSync(temporary, file);
|
|
106
|
+
} finally {
|
|
107
|
+
if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
|
|
108
|
+
}
|
|
109
|
+
} finally {
|
|
110
|
+
held.release();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Update named keys through the same canonical sealed-secret owner used by the CLI. */
|
|
115
|
+
export function setSecretValues(
|
|
116
|
+
repoRoot,
|
|
117
|
+
{ slug, values, env = null },
|
|
118
|
+
{ sops = null, age = [], processEnv = process.env } = {},
|
|
119
|
+
) {
|
|
120
|
+
const directory = secretsDirectory(repoRoot, env);
|
|
121
|
+
const file = fileOf(directory, slug);
|
|
122
|
+
if (
|
|
123
|
+
!values ||
|
|
124
|
+
typeof values !== "object" ||
|
|
125
|
+
Array.isArray(values) ||
|
|
126
|
+
!Object.keys(values).length
|
|
127
|
+
)
|
|
128
|
+
throw new SecretError("secret update requires named values");
|
|
129
|
+
for (const value of Object.values(values))
|
|
130
|
+
if (typeof value !== "string" || value === "")
|
|
131
|
+
throw new SecretError("secret values must be nonempty strings");
|
|
132
|
+
writeSecret({
|
|
133
|
+
file,
|
|
134
|
+
values,
|
|
135
|
+
age,
|
|
136
|
+
sops: sops ?? defaultSops(processEnv, repoRoot),
|
|
137
|
+
});
|
|
138
|
+
return { file, via: "sealed-secret" };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function sealedFiles(directory) {
|
|
142
|
+
return fs.existsSync(directory)
|
|
143
|
+
? fs
|
|
144
|
+
.readdirSync(directory)
|
|
145
|
+
.filter((name) => name.endsWith(".enc"))
|
|
146
|
+
.sort(byCodeUnit)
|
|
147
|
+
: [];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function readMap(file, sops) {
|
|
151
|
+
if (!fs.existsSync(file))
|
|
152
|
+
throw new SecretError(
|
|
153
|
+
"the requested secret is not sealed in this environment",
|
|
154
|
+
);
|
|
155
|
+
const map = sops.decrypt(
|
|
156
|
+
file,
|
|
157
|
+
envelopeOf(fs.readFileSync(file, "utf8")).format,
|
|
158
|
+
);
|
|
159
|
+
validateMap(map);
|
|
160
|
+
return map;
|
|
161
|
+
}
|
|
@@ -1,220 +1,160 @@
|
|
|
1
|
-
// secret.mjs -
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
import
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const SLUG = /^[a-z0-9][a-z0-9-]*$/u;
|
|
28
|
-
const KEY = /^[A-Za-z_]\w*$/u;
|
|
29
|
-
const WHITESPACE = String.raw`\s`;
|
|
30
|
-
const REQUIRED_INDENT = `${WHITESPACE}+`;
|
|
31
|
-
const OPTIONAL_DASH = '-?';
|
|
32
|
-
const OPTIONAL_SPACING = `${WHITESPACE}*`;
|
|
33
|
-
const RECIPIENT_NAME = 'recipient:';
|
|
34
|
-
const RECIPIENT_VALUE = String.raw`(\S+)`;
|
|
35
|
-
const RECIPIENT_LINE = new RegExp(`^${REQUIRED_INDENT}${OPTIONAL_DASH}${OPTIONAL_SPACING}${RECIPIENT_NAME}${OPTIONAL_SPACING}${RECIPIENT_VALUE}`, 'gmu');
|
|
36
|
-
const DEFAULT_KEY = 'data';
|
|
37
|
-
const VERBS = new Set(['list', 'show', 'set', 'gen']);
|
|
38
|
-
|
|
39
|
-
/** The envelope format of a sealed secret, read from its text: json, yaml or dotenv. */
|
|
40
|
-
export function formatOf(text) {
|
|
41
|
-
const body = String(text).trimStart();
|
|
42
|
-
if (body.startsWith('{')) return 'json';
|
|
43
|
-
if (/^sops:\s*$/mu.test(text)) return 'yaml';
|
|
44
|
-
return 'dotenv';
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** The key names a sealed document holds and the age recipients it is sealed to, read without decrypting anything. */
|
|
48
|
-
export function envelopeOf(text) {
|
|
49
|
-
const format = formatOf(text);
|
|
50
|
-
if (format === 'json') {
|
|
51
|
-
const doc = JSON.parse(text);
|
|
52
|
-
return { format, keys: Object.keys(doc).filter((name) => name !== 'sops'), recipients: (doc.sops?.age ?? []).map((entry) => entry.recipient).filter(Boolean) };
|
|
53
|
-
}
|
|
54
|
-
if (format === 'yaml') {
|
|
55
|
-
const keys = [...text.matchAll(/^([A-Za-z_]\w*):/gmu)].map((match) => match[1]).filter((name) => name !== 'sops');
|
|
56
|
-
return { format, keys, recipients: [...text.matchAll(RECIPIENT_LINE)].map((match) => match[1]) };
|
|
57
|
-
}
|
|
58
|
-
const keys = [...text.matchAll(/^([A-Za-z_]\w*)=/gmu)].map((match) => match[1]).filter((name) => !name.startsWith('sops_'));
|
|
59
|
-
return { format, keys, recipients: [...text.matchAll(/^sops_age__list_\d+__map_recipient=(\S+)/gmu)].map((match) => match[1]) };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** The plaintext document `map` in the given format. A dotenv value cannot hold a line break. */
|
|
63
|
-
export function plaintextOf(format, map) {
|
|
64
|
-
if (format === 'json') return `${JSON.stringify(map)}\n`;
|
|
65
|
-
if (format === 'yaml') return Object.entries(map).map(([name, value]) => name + ': ' + JSON.stringify(value)).join('\n') + '\n';
|
|
66
|
-
for (const [name, value] of Object.entries(map)) if (/[\r\n]/u.test(value)) throw new SecretError(`${name} holds a line break; a dotenv secret holds one-line values only`);
|
|
67
|
-
return Object.entries(map).map(([name, value]) => name + '=' + value).join('\n') + '\n';
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** The directory of the sealed secrets of the app at `repoRoot` for `env`; the one env that has secrets when none is named. */
|
|
71
|
-
function secretsDirectory(repoRoot, env) {
|
|
72
|
-
const stacks = path.join(repoRoot, '.starcistacks');
|
|
73
|
-
if (!fs.existsSync(stacks)) throw new SecretError('this app has no .starcistacks; run starci app secret at the app root, or pass --cwd');
|
|
74
|
-
if (env) {
|
|
75
|
-
if (!/^[a-z0-9][a-z0-9-]*$/u.test(env)) throw new SecretError(`--env ${env} is not an environment name`);
|
|
76
|
-
return path.join(stacks, env, 'secrets');
|
|
77
|
-
}
|
|
78
|
-
const withSecrets = fs.readdirSync(stacks, { withFileTypes: true }).filter((entry) => entry.isDirectory() && fs.existsSync(path.join(stacks, entry.name, 'secrets'))).map((entry) => entry.name);
|
|
79
|
-
if (withSecrets.length === 1) return path.join(stacks, withSecrets[0], 'secrets');
|
|
80
|
-
throw new SecretError(withSecrets.length === 0 ? 'no environment of .starcistacks has a secrets folder; pass --env to create the first secret' : `more than one environment has secrets (${withSecrets.join(', ')}); pass --env`);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const fileOf = (directory, slug) => {
|
|
84
|
-
if (!SLUG.test(slug ?? '')) throw new SecretError(`${slug === undefined ? 'a secret name' : slug} is not a secret slug: lowercase letters, digits and dashes`);
|
|
85
|
-
return path.join(directory, `${slug}.enc`);
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
/** The default sops seam: the runtime's sops api, which finds the binary on this machine. */
|
|
89
|
-
function defaultSops(env = process.env) {
|
|
90
|
-
const refuse = (what, result) => {
|
|
91
|
-
if (result.error?.identityRefusal) return Object.assign(new SecretError(result.error.message), { identityRefusal: result.error.identityRefusal });
|
|
92
|
-
if (result.error?.code === 'SOPS_MISSING') return new SecretError(result.error.message);
|
|
93
|
-
return new SecretError(`sops could not ${what} (exit ${String(result.status)})`);
|
|
94
|
-
};
|
|
95
|
-
return {
|
|
96
|
-
decrypt(file, format) {
|
|
97
|
-
const result = sopsDecrypt(null, ['decrypt', '--input-type', format, '--output-type', 'json', file], { env, invocation: sopsInvocation });
|
|
98
|
-
if (result.status !== 0) throw refuse(`decrypt ${path.basename(file)}; is the age identity installed?`, result);
|
|
99
|
-
try { return JSON.parse(result.stdout); } catch { throw new SecretError('sops returned an invalid plaintext document'); }
|
|
100
|
-
},
|
|
101
|
-
seal(request) {
|
|
102
|
-
const result = sopsSeal(null, request, { env, invocation: sopsInvocation });
|
|
103
|
-
if (result.status !== 0) throw refuse('seal the secret', result);
|
|
104
|
-
return result.stdout;
|
|
105
|
-
},
|
|
106
|
-
};
|
|
1
|
+
// secret.mjs - app secret init|set|get|materialize|list: sealed custody and private KEY files.
|
|
2
|
+
// SOPS plaintext travels on stdin and stays in memory; normal CLI output contains names and local file paths only.
|
|
3
|
+
// Every verb runs at the app root or --cwd. Exit 0 done, 2 refusal; --reveal is the only plaintext stdout path.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
import { invocationDir } from "../lib/roots.mjs";
|
|
8
|
+
import { initializeSecrets } from "./secret-init.mjs";
|
|
9
|
+
import { validateKey } from "./secret-document.mjs";
|
|
10
|
+
import { defaultSops } from "./secret-sops.mjs";
|
|
11
|
+
import { fileOf, secretsDirectory, writeSecret } from "./secret-store.mjs";
|
|
12
|
+
import { getSecret, listSecrets, materializeSecrets } from "./secret-output.mjs";
|
|
13
|
+
import { SecretError, publicStorage, secretErrorMessage } from "./secret-errors.mjs";
|
|
14
|
+
|
|
15
|
+
export { envelopeOf, formatOf, plaintextOf } from "./secret-document.mjs";
|
|
16
|
+
export { setSecretValues } from "./secret-store.mjs";
|
|
17
|
+
|
|
18
|
+
const VERBS = new Set(["init", "list", "get", "set", "materialize"]);
|
|
19
|
+
|
|
20
|
+
function valueOption(opts, flag, value) {
|
|
21
|
+
if (value === undefined || value.startsWith("--"))
|
|
22
|
+
throw new SecretError(`${flag} needs a value`);
|
|
23
|
+
if (Object.hasOwn(opts, flag.slice(2)))
|
|
24
|
+
throw new SecretError(`${flag} may be given only once`);
|
|
25
|
+
opts[flag.slice(2)] = value;
|
|
107
26
|
}
|
|
108
27
|
|
|
109
28
|
function parse(argv) {
|
|
110
|
-
const opts = { positional: []
|
|
29
|
+
const opts = { positional: [] };
|
|
111
30
|
for (let i = 0; i < argv.length; i += 1) {
|
|
112
31
|
const arg = argv[i];
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
32
|
+
const [flag, inline] = arg.split(/=(.*)/su);
|
|
33
|
+
if (["--cwd", "--generate"].includes(flag)) {
|
|
34
|
+
valueOption(opts, flag, inline ?? argv[++i]);
|
|
35
|
+
} else if (["--to-file", "--reveal"].includes(flag)) {
|
|
36
|
+
if (inline !== undefined && !["true", "false"].includes(inline))
|
|
37
|
+
throw new SecretError(`${flag} expects true or false`);
|
|
38
|
+
opts[flag.slice(2)] = inline !== "false";
|
|
39
|
+
} else if (arg.startsWith("--"))
|
|
40
|
+
throw new SecretError(`unknown flag ${arg}`);
|
|
118
41
|
else opts.positional.push(arg);
|
|
119
42
|
}
|
|
120
43
|
return opts;
|
|
121
44
|
}
|
|
122
45
|
|
|
123
|
-
const readStdin = () =>
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
try {
|
|
131
|
-
let format = 'json';
|
|
132
|
-
let map = {};
|
|
133
|
-
let recipients = age;
|
|
134
|
-
if (fs.existsSync(file)) {
|
|
135
|
-
const text = fs.readFileSync(file, 'utf8');
|
|
136
|
-
const envelope = envelopeOf(text);
|
|
137
|
-
format = envelope.format;
|
|
138
|
-
map = sops.decrypt(file, format);
|
|
139
|
-
if (!age.length) recipients = envelope.recipients;
|
|
140
|
-
}
|
|
141
|
-
Object.assign(map, values);
|
|
142
|
-
const sealed = sops.seal({ inputType: format, plaintext: plaintextOf(format, map), recipients, filenameOverride: file });
|
|
143
|
-
let parsed;
|
|
144
|
-
try { parsed = envelopeOf(sealed); } catch { throw new SecretError('sops returned an invalid sealed document'); }
|
|
145
|
-
if (!isSopsEnvelope(sealed) || parsed.keys.length !== Object.keys(map).length || !parsed.keys.every(key => Object.hasOwn(map, key))) throw new SecretError('sops returned an invalid sealed document');
|
|
146
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
147
|
-
const temporary = path.join(path.dirname(file), `.${path.basename(file)}-${randomUUID()}.tmp`);
|
|
148
|
-
try {
|
|
149
|
-
fs.writeFileSync(temporary, sealed, { flag: 'wx', mode: 0o600, flush: true });
|
|
150
|
-
fs.renameSync(temporary, file);
|
|
151
|
-
} finally { if (fs.existsSync(temporary)) fs.unlinkSync(temporary); }
|
|
152
|
-
} finally { held.release(); }
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** Update named keys through the same canonical sealed-secret owner used by the CLI. */
|
|
156
|
-
export function setSecretValues(repoRoot, { slug, values, env = null }, { sops = null, age = [], processEnv = process.env } = {}) {
|
|
157
|
-
const directory = secretsDirectory(repoRoot, env);
|
|
158
|
-
const file = fileOf(directory, slug);
|
|
159
|
-
if (!values || typeof values !== 'object' || Array.isArray(values) || !Object.keys(values).length) throw new SecretError('secret update requires named values');
|
|
160
|
-
for (const value of Object.values(values)) if (typeof value !== 'string' || value === '') throw new SecretError('secret values must be nonempty strings');
|
|
161
|
-
writeSecret({ file, values, age, sops: sops ?? defaultSops(processEnv) });
|
|
162
|
-
return { file, via: 'sealed-secret' };
|
|
163
|
-
}
|
|
46
|
+
const readStdin = () => {
|
|
47
|
+
if (process.stdin.isTTY)
|
|
48
|
+
throw new SecretError(
|
|
49
|
+
"set reads a value from redirected stdin; use --generate <bytes> to generate one",
|
|
50
|
+
);
|
|
51
|
+
return fs.readFileSync(0, "utf8");
|
|
52
|
+
};
|
|
164
53
|
|
|
165
|
-
function
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
return 0;
|
|
54
|
+
function usageArguments(verb) {
|
|
55
|
+
if (verb === "init") return "";
|
|
56
|
+
if (verb === "set") return "<env> <slug> <KEY> [--generate <bytes>]";
|
|
57
|
+
if (verb === "get") return "<env> <slug> [KEY] --to-file [--reveal]";
|
|
58
|
+
return "<env>";
|
|
171
59
|
}
|
|
172
60
|
|
|
173
|
-
function
|
|
174
|
-
if (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if (
|
|
180
|
-
|
|
181
|
-
return 0;
|
|
61
|
+
function outputOptionsFor(verb, opts) {
|
|
62
|
+
if (
|
|
63
|
+
(opts["to-file"] !== undefined || opts.reveal !== undefined) &&
|
|
64
|
+
verb !== "get"
|
|
65
|
+
)
|
|
66
|
+
throw new SecretError("--to-file and --reveal are only valid for get");
|
|
67
|
+
if (verb === "get" && !opts["to-file"] && !opts.reveal)
|
|
68
|
+
throw new SecretError("get requires --to-file or explicit --reveal");
|
|
182
69
|
}
|
|
183
70
|
|
|
184
|
-
function
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
71
|
+
function optionsFor(verb, opts) {
|
|
72
|
+
const count = opts.positional.length;
|
|
73
|
+
const bounds = {
|
|
74
|
+
init: [0, 0],
|
|
75
|
+
list: [1, 1],
|
|
76
|
+
materialize: [1, 1],
|
|
77
|
+
set: [3, 3],
|
|
78
|
+
get: [2, 3],
|
|
79
|
+
}[verb];
|
|
80
|
+
if (count < bounds[0] || count > bounds[1])
|
|
81
|
+
throw new SecretError(
|
|
82
|
+
`usage: starci app secret ${verb} ${usageArguments(verb)}`,
|
|
83
|
+
);
|
|
84
|
+
if (opts.generate !== undefined && verb !== "set")
|
|
85
|
+
throw new SecretError("--generate is only valid for set");
|
|
86
|
+
outputOptionsFor(verb, opts);
|
|
87
|
+
if (verb === "set") validateKey(opts.positional[2]);
|
|
88
|
+
if (verb === "get" && opts.positional[2] !== undefined)
|
|
89
|
+
validateKey(opts.positional[2]);
|
|
190
90
|
}
|
|
191
91
|
|
|
192
|
-
function
|
|
193
|
-
|
|
194
|
-
if (
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
92
|
+
function secretValue(opts, stdin, random) {
|
|
93
|
+
let value;
|
|
94
|
+
if (opts.generate !== undefined) {
|
|
95
|
+
const bytes = Number(opts.generate);
|
|
96
|
+
if (!Number.isInteger(bytes) || bytes < 1 || bytes > 1024)
|
|
97
|
+
throw new SecretError(
|
|
98
|
+
"--generate must be a whole byte count from 1 to 1024",
|
|
99
|
+
);
|
|
100
|
+
value = random(bytes).toString("base64url");
|
|
101
|
+
} else {
|
|
102
|
+
if (typeof stdin !== "function" && stdin?.isTTY)
|
|
103
|
+
throw new SecretError("set requires redirected stdin");
|
|
104
|
+
value = String(
|
|
105
|
+
typeof stdin === "function"
|
|
106
|
+
? stdin()
|
|
107
|
+
: fs.readFileSync(stdin?.fd ?? 0, "utf8"),
|
|
108
|
+
).replace(/\r?\n$/u, "");
|
|
109
|
+
}
|
|
110
|
+
if (!value) throw new SecretError("the value on stdin is empty");
|
|
111
|
+
return value;
|
|
198
112
|
}
|
|
199
113
|
|
|
200
|
-
/**
|
|
201
|
-
export function secretMain(
|
|
114
|
+
/** All app secrets verbs; stdin, sops, random and tool ports permit isolated custody specs. */
|
|
115
|
+
export function secretMain(
|
|
116
|
+
argv,
|
|
117
|
+
{
|
|
118
|
+
stdout = (s) => process.stdout.write(s),
|
|
119
|
+
stderr = (s) => process.stderr.write(s),
|
|
120
|
+
stdin = readStdin,
|
|
121
|
+
sops = null,
|
|
122
|
+
random = randomBytes,
|
|
123
|
+
env = process.env,
|
|
124
|
+
cwd = invocationDir(),
|
|
125
|
+
platform = process.platform,
|
|
126
|
+
tools,
|
|
127
|
+
} = {},
|
|
128
|
+
) {
|
|
202
129
|
const [verb, ...rest] = argv;
|
|
203
130
|
try {
|
|
204
|
-
if (!VERBS.has(verb))
|
|
131
|
+
if (!VERBS.has(verb))
|
|
132
|
+
throw new SecretError(
|
|
133
|
+
"usage: starci app secret init | set <env> <slug> <KEY> | get <env> <slug> [KEY] --to-file | materialize <env> | list <env>",
|
|
134
|
+
);
|
|
205
135
|
const opts = parse(rest);
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
if (verb ===
|
|
215
|
-
|
|
136
|
+
optionsFor(verb, opts);
|
|
137
|
+
const repoRoot = path.resolve(cwd, opts.cwd ?? ".");
|
|
138
|
+
if (verb === "init")
|
|
139
|
+
return publicStorage(() =>
|
|
140
|
+
initializeSecrets(repoRoot, { env, platform, tools, stdout }),
|
|
141
|
+
);
|
|
142
|
+
const [environment, slug, key] = opts.positional;
|
|
143
|
+
const directory = secretsDirectory(repoRoot, environment);
|
|
144
|
+
if (verb === "list") return listSecrets(directory, stdout);
|
|
145
|
+
const seam = sops ?? defaultSops(env, repoRoot, platform, tools);
|
|
146
|
+
const output = { repoRoot, environment, env, platform, stdout };
|
|
147
|
+
if (verb === "materialize")
|
|
148
|
+
return materializeSecrets(directory, seam, output);
|
|
149
|
+
const file = fileOf(directory, slug);
|
|
150
|
+
if (verb === "get") return getSecret(file, key, seam, opts, output, stderr);
|
|
151
|
+
const value = secretValue(opts, stdin, random);
|
|
152
|
+
writeSecret({ file, values: { [key]: value }, age: [], sops: seam });
|
|
153
|
+
stdout(`sealed ${environment}/${slug} (${key})\n`);
|
|
154
|
+
return 0;
|
|
216
155
|
} catch (error) {
|
|
217
|
-
|
|
218
|
-
|
|
156
|
+
const message = secretErrorMessage(error);
|
|
157
|
+
stderr(`starci app secret: ${message}\n`);
|
|
158
|
+
return 2;
|
|
219
159
|
}
|
|
220
160
|
}
|
|
@@ -65,8 +65,10 @@ export function importAllowedIn(scope, fromPath, toPath) {
|
|
|
65
65
|
const fromOwner = ownerIn(scope, from.path);
|
|
66
66
|
const toOwner = ownerIn(scope, to.path);
|
|
67
67
|
if (sameOwnerUnit(from, fromOwner) === sameOwnerUnit(to, toOwner)) return { allowed: true, reason: 'sameOwner' };
|
|
68
|
-
const { mayImport } = scope.manifest.tiers[scope.profile][fromTier] ?? {};
|
|
68
|
+
const { mayImport, mayImportKinds } = scope.manifest.tiers[scope.profile][fromTier] ?? {};
|
|
69
69
|
if (!mayImport?.includes(toTier)) return { allowed: false, reason: 'tierDirection', fromTier, toTier, mayImport: mayImport ?? [] };
|
|
70
|
+
const kindRestriction = mayImportKinds?.[toTier];
|
|
71
|
+
if (kindRestriction && (!kindRestriction.from.includes(from.kind) || !kindRestriction.to.includes(to.kind))) return { allowed: false, reason: 'tierDirection', fromTier, toTier, mayImport, kindRestriction, fromKind: from.kind ?? null, toKind: to.kind ?? null };
|
|
70
72
|
const layerProblem = lowerLayerProblem(scope, from, to, fromTier, toTier, fromOwner, toOwner);
|
|
71
73
|
if (layerProblem) return layerProblem;
|
|
72
74
|
// A runtime owner is imported file by file (crossOwner of knowledge/hfs/runtime-slots.yaml): no public entry.
|
|
@@ -5,6 +5,19 @@ import { braceVariants } from '../lib/glob.mjs';
|
|
|
5
5
|
import { classifyIn, slotEnabled } from './slot-classify.mjs';
|
|
6
6
|
import { fillVars, varsOf } from './slot-match.mjs';
|
|
7
7
|
|
|
8
|
+
/** A populated fe.modules instance needs its index.ts only when it contains TypeScript, including nested files. */
|
|
9
|
+
export function requiredFileApplies(slotId, target, files) {
|
|
10
|
+
if (!/^fe\.modules(?:\.|$)/u.test(slotId) || path.posix.basename(target) !== 'index.ts') return true;
|
|
11
|
+
const prefix = `${path.posix.dirname(target)}/`;
|
|
12
|
+
let populated = false;
|
|
13
|
+
for (const file of files) {
|
|
14
|
+
if (!file.startsWith(prefix)) continue;
|
|
15
|
+
populated = true;
|
|
16
|
+
if (/\.tsx?$/u.test(file)) return true;
|
|
17
|
+
}
|
|
18
|
+
return !populated;
|
|
19
|
+
}
|
|
20
|
+
|
|
8
21
|
/** One `requires` entry of a slot placed under `root`: a leading / makes it repository-relative, the variables are filled from `bindings`. */
|
|
9
22
|
function requiredEntry(entry, bindings, root) {
|
|
10
23
|
const rooted = entry.startsWith('/');
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { isPlainObject } from '../../engine/plain-object.mjs';
|
|
3
3
|
import { triggerProblems } from './declaration-slots.mjs';
|
|
4
4
|
import { litePresenceOf, slotInEdition } from './edition-slots.mjs';
|
|
5
|
-
import { manifestKind, RUNTIME_KIND, runtimeSemanticProblems } from './manifest-shape.mjs';
|
|
5
|
+
import { manifestKind, RUNTIME_KIND, runtimeSemanticProblems, tierKindProblems } from './manifest-shape.mjs';
|
|
6
6
|
import { APP_SCOPE, PROFILES } from './slot-manifest-shape.mjs';
|
|
7
7
|
|
|
8
8
|
function checkAppRootSlot(slot, bad) {
|
|
@@ -113,6 +113,7 @@ function checkSideReads(manifest, bad, braceVariants) {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
function checkProfileTierTargets(manifest, profile, bad) {
|
|
116
|
+
bad.push(...tierKindProblems(manifest, profile));
|
|
116
117
|
for (const [tier, def] of Object.entries(manifest.tiers[profile])) {
|
|
117
118
|
for (const target of def.mayImport) {
|
|
118
119
|
if (!(target in manifest.tiers[profile])) bad.push(`tiers.${profile}.${tier}.mayImport names ${target}, which is not a ${profile} tier`);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readTree, treeFacts, untrackedEntries } from './tree.mjs';
|
|
2
|
+
import { liteStarciworkTreeFindings } from './path-findings.mjs';
|
|
3
|
+
|
|
4
|
+
/** The tree findings of R03: empty directories, ghost siblings, untracked entries outside an ignored slot. */
|
|
5
|
+
export function treeFindings({ repoRoot, resolver, tracked = [], reportedUntracked = new Set() }) {
|
|
6
|
+
const inIgnoredSlot = (rel) => resolver.classifyPath(`${rel}/.probe`).tracking === 'ignored';
|
|
7
|
+
const findings = liteStarciworkTreeFindings(repoRoot, resolver, tracked);
|
|
8
|
+
const facts = treeFacts(readTree(repoRoot, { isIgnored: inIgnoredSlot }));
|
|
9
|
+
for (const { path: dir, below } of facts.empty) {
|
|
10
|
+
findings.push({ code: 'HFS_EMPTY_DIR', level: 'error', path: dir, below, message: `${dir} has no file below it` + (below ? ` (nor in its ${below} sub-director${below === 1 && 'y' || 'ies'})` : '') + '; git tracks no empty directory, so it is a leftover' });
|
|
11
|
+
}
|
|
12
|
+
for (const { path: dir, of, distance } of facts.ghosts) {
|
|
13
|
+
findings.push({ code: 'HFS_GHOST_TREE', level: 'error', path: dir, of, distance, message: `${dir} is empty and ${distance} edit${distance === 1 ? '' : 's'} from its sibling ${of}: a renamed or misspelt structure that was never removed` });
|
|
14
|
+
}
|
|
15
|
+
for (const entry of untrackedEntries(repoRoot)) {
|
|
16
|
+
const bare = entry.replace(/\/$/, '');
|
|
17
|
+
if (reportedUntracked.has(bare)) continue;
|
|
18
|
+
const ignored = entry.endsWith('/') ? inIgnoredSlot(bare) : resolver.classifyPath(bare).tracking === 'ignored';
|
|
19
|
+
if (!ignored) findings.push({ code: 'HFS_UNTRACKED_ROOT_ENTRY', level: 'error', path: bare, message: `${entry} is neither tracked nor git-ignored, and no ignored slot owns it` });
|
|
20
|
+
}
|
|
21
|
+
return findings;
|
|
22
|
+
}
|