@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.
Files changed (108) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +7 -0
  3. package/package.json +1 -1
  4. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  5. package/runtime/knowledge/hfs/rules.yaml +41 -13
  6. package/runtime/knowledge/hfs/slots.yaml +99 -43
  7. package/runtime/knowledge/patterns/be/api.yaml +64 -1
  8. package/runtime/knowledge/patterns/be/cli.yaml +23 -5
  9. package/runtime/knowledge/patterns/be/webhooks.yaml +187 -32
  10. package/runtime/knowledge/patterns/fe/folder.yaml +3 -0
  11. package/runtime/modules/kernel/failure-codes.yaml +15 -4
  12. package/runtime/scripts/api/git/cat-file.mjs +5 -0
  13. package/runtime/scripts/api/sops/resolve-sops.mjs +7 -0
  14. package/runtime/scripts/api/sops/seal.mjs +1 -1
  15. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +110 -0
  16. package/runtime/scripts/hfs/architecture/backend.mjs +20 -3
  17. package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
  18. package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
  19. package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
  20. package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
  21. package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
  22. package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
  23. package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
  24. package/runtime/scripts/hfs/architecture/dead-exports.mjs +14 -9
  25. package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
  26. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
  27. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
  28. package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
  29. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +21 -12
  30. package/runtime/scripts/hfs/architecture/register-once.mjs +13 -5
  31. package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
  32. package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
  33. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +3 -6
  34. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
  35. package/runtime/scripts/hfs/architecture/tiers.mjs +8 -1
  36. package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
  37. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +1 -11
  38. package/runtime/scripts/hfs/check.mjs +18 -26
  39. package/runtime/scripts/hfs/manifest-shape.mjs +27 -2
  40. package/runtime/scripts/hfs/path-findings.mjs +32 -2
  41. package/runtime/scripts/hfs/rules/database-migrations.mjs +36 -35
  42. package/runtime/scripts/hfs/rules/database.mjs +2 -2
  43. package/runtime/scripts/hfs/rules/edition.mjs +5 -5
  44. package/runtime/scripts/hfs/rules/proof-commands.mjs +2 -0
  45. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  46. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +128 -0
  47. package/runtime/scripts/hfs/rules/stacks.mjs +13 -8
  48. package/runtime/scripts/hfs/secret-document.mjs +97 -0
  49. package/runtime/scripts/hfs/secret-errors.mjs +20 -0
  50. package/runtime/scripts/hfs/secret-files.mjs +124 -0
  51. package/runtime/scripts/hfs/secret-init.mjs +192 -0
  52. package/runtime/scripts/hfs/secret-output.mjs +100 -0
  53. package/runtime/scripts/hfs/secret-sops.mjs +98 -0
  54. package/runtime/scripts/hfs/secret-store.mjs +161 -0
  55. package/runtime/scripts/hfs/secret.mjs +135 -195
  56. package/runtime/scripts/hfs/slot-imports.mjs +3 -1
  57. package/runtime/scripts/hfs/slot-required.mjs +13 -0
  58. package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
  59. package/runtime/scripts/hfs/tree-findings.mjs +22 -0
  60. package/runtime/scripts/lib/roots.mjs +59 -0
  61. package/runtime/scripts/lib/swarm-stack.mjs +139 -0
  62. package/runtime/scripts/lib/ts-analysis.mjs +20 -0
  63. package/scaffold/add-cli-lite.mjs +10 -11
  64. package/scaffold/add-inbox-lite.mjs +15 -84
  65. package/scaffold/add-plan.mjs +183 -0
  66. package/scaffold/add-table.mjs +44 -5
  67. package/scaffold/add.mjs +33 -166
  68. package/scaffold/app.mjs +5 -5
  69. package/scaffold/image.mjs +2 -2
  70. package/src/main.mjs +12 -6
  71. package/sync/index.mjs +23 -16
  72. package/templates/app/gitignore +1 -0
  73. package/templates/app/gitignore-lite +1 -0
  74. package/templates/app/hooks/husky/pre-commit +7 -12
  75. package/templates/app/skeleton/.sops.yaml +1 -1
  76. package/templates/app/skeleton-lite/.sops.yaml +1 -1
  77. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +16 -0
  78. package/templates/app/skeleton-lite/.starciwork/workspace.yaml +9 -0
  79. package/templates/app/skeleton-lite/README.md +4 -2
  80. package/templates/app/starciwork.gitignore-lite +6 -0
  81. package/templates/be/inbox/database-client.spec.ts.tpl +15 -20
  82. package/templates/be/inbox/database-client.ts.tpl +7 -22
  83. package/templates/be/inbox/service.spec.ts.tpl +16 -20
  84. package/templates/be/inbox/service.ts.tpl +6 -10
  85. package/templates/be/inbox/sql.ts.tpl +2 -2
  86. package/templates/be/inbox/webhook-door.spec.ts.tpl +3 -3
  87. package/templates/be/inbox/webhook-door.ts.tpl +4 -3
  88. package/templates/be/patterns/webhooks/webhook.door.spec.ts.tpl +3 -3
  89. package/templates/be/patterns/webhooks/webhook.door.ts.tpl +3 -3
  90. package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +21 -0
  91. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +1 -1
  92. package/templates/be/skeleton/apps/__app__/src/app.module.ts +7 -39
  93. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +42 -0
  94. package/templates/be/skeleton/apps/__app__/src/main.ts +3 -9
  95. package/templates/be/skeleton/apps/cli/src/cli.env.ts +13 -0
  96. package/templates/be/skeleton/apps/cli/src/cli.options.ts +6 -8
  97. package/templates/be/skeleton/apps/cli/src/main.ts +3 -2
  98. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +6 -6
  99. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +25 -0
  100. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +1 -1
  101. package/templates/be/skeleton-lite/apps/api/src/app.module.ts +6 -42
  102. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +45 -0
  103. package/templates/be/skeleton-lite/apps/api/src/main.ts +4 -11
  104. package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +17 -0
  105. package/upgrade/file-state.mjs +68 -0
  106. package/upgrade/index.mjs +7 -56
  107. package/runtime/scripts/api/git/log.mjs +0 -5
  108. 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 - `starci app secret list|show|set|gen`: the canon command over an app's sealed secrets.
2
- // (R06: a secret exists only as a sops envelope there). It is the one way an operator or an agent reads or writes one; a plain
3
- // `sops` command in a KEYS.md or a runbook is not the way. Every sops use goes through scripts/api/sops (decrypt.mjs, seal.mjs):
4
- // starci app secret list [--env <name>] every secret of the env and the keys it holds (names only, nothing decrypted)
5
- // starci app secret show <slug> [--key <NAME>] [--env <name>] decrypt one value to stdout
6
- // starci app secret set <slug> [--key <NAME>] [--age <recipient>]... [--env <name>]
7
- // the value is read from stdin (never from an argument); a new secret takes its
8
- // recipients from --age, else from the repository's .sops.yaml
9
- // starci app secret gen <slug> [--key <NAME>] [--bytes <n>] [--age <recipient>]... [--env <name>]
10
- // a random value (base64url of n bytes, default 32) sealed without being printed
11
- // A single-value secret holds its value under the key `data`. Every verb runs at the app root (the folder of hfs.json), or at --cwd.
12
- // Exit 0 done, 2 a refusal or bad usage (a secret value is never part of a message).
13
- import fs from 'node:fs';
14
- import path from 'node:path';
15
- import { runProgram } from '../api/process/run-program.mjs';
16
- import { resolveRealTool } from '../api/process/resolve-real-tool.mjs';
17
- const sopsInvocation = Object.freeze({runProgram,resolveRealTool});
18
- import { randomBytes, randomUUID } from 'node:crypto';
19
- import { claimFile } from '../api/fs/claim-file.mjs';
20
- import { isSopsEnvelope } from '../lib/sops-envelope.mjs';
21
- import { decrypt as sopsDecrypt } from '../api/sops/decrypt.mjs';
22
- import { seal as sopsSeal } from '../api/sops/seal.mjs';
23
- import { byCodeUnit } from '../lib/list.mjs';
24
-
25
- class SecretError extends Error {}
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: [], age: [] };
29
+ const opts = { positional: [] };
111
30
  for (let i = 0; i < argv.length; i += 1) {
112
31
  const arg = argv[i];
113
- if (['--cwd', '--env', '--key', '--bytes', '--age'].includes(arg)) {
114
- if (argv[i + 1] === undefined) throw new SecretError(`${arg} needs a value`);
115
- if (arg === '--age') opts.age.push(argv[i + 1]); else opts[arg.slice(2)] = argv[i + 1];
116
- i += 1;
117
- } else if (arg.startsWith('--')) throw new SecretError(`unknown flag ${arg}`);
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 = () => fs.readFileSync(0, 'utf8');
124
-
125
- /** Seal `key = value` into the secret `slug`: an existing document keeps its other keys and its recipients. */
126
- function writeSecret({ file, values, age, sops }) {
127
- for (const key of Object.keys(values)) if (!KEY.test(key)) throw new SecretError(`${key} is not a key name`);
128
- const held = claimFile(path.resolve(file));
129
- if (!held.ok) throw new SecretError('the sealed secret is being updated; retry after that write settles');
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 listSecrets(opts, directory, repoRoot, stdout) {
166
- if (opts.positional.length) throw new SecretError('starci app secret list takes no name');
167
- const files = fs.existsSync(directory) ? fs.readdirSync(directory).filter((name) => name.endsWith('.enc')).sort(byCodeUnit) : [];
168
- for (const name of files) stdout(`${name.slice(0, -'.enc'.length)} ${envelopeOf(fs.readFileSync(path.join(directory, name), 'utf8')).keys.join(', ')}\n`);
169
- stdout(`${files.length} secret${files.length === 1 ? '' : 's'} in ${path.relative(repoRoot, directory).split(path.sep).join('/')}\n`);
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 showSecret(opts, file, seam, stdout) {
174
- if (!fs.existsSync(file)) throw new SecretError(`${opts.positional[0]} is not sealed in this env`);
175
- const { format, keys } = envelopeOf(fs.readFileSync(file, 'utf8'));
176
- if (opts.key === undefined && !keys.includes(DEFAULT_KEY) && keys.length !== 1) throw new SecretError(`${opts.positional[0]} holds ${keys.join(', ')}; name one with --key`);
177
- const map = seam.decrypt(file, format);
178
- const name = opts.key ?? (keys.includes(DEFAULT_KEY) ? DEFAULT_KEY : keys[0]);
179
- if (!Object.hasOwn(map, name)) throw new SecretError(`${opts.positional[0]} has no key ${name}`);
180
- stdout(`${String(map[name])}\n`);
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 setSecretFromStdin(opts, file, key, seam, stdin, stdout) {
185
- const value = String(stdin()).replace(/\r?\n$/u, '');
186
- if (value === '') throw new SecretError('the value on stdin is empty');
187
- writeSecret({ file, values: { [key]: value }, age: opts.age, sops: seam });
188
- stdout(`sealed ${opts.positional[0]} (${key})\n`);
189
- return 0;
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 generateSecret(opts, file, key, seam, random, stdout) {
193
- const bytes = opts.bytes === undefined ? 32 : Number(opts.bytes);
194
- if (!Number.isInteger(bytes) || bytes < 16 || bytes > 1024) throw new SecretError('--bytes is a whole number from 16 to 1024');
195
- writeSecret({ file, values: { [key]: random(bytes).toString('base64url') }, age: opts.age, sops: seam });
196
- stdout(`generated and sealed ${opts.positional[0]} (${key}, ${bytes} bytes); read it with starci app secret show\n`);
197
- return 0;
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
- /** `starci app secret <verb> ...`; `stdin` returns the value `set` seals, `sops` and `random` are test seams. */
201
- export function secretMain(argv, { stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), stdin = readStdin, sops = null, random = randomBytes, env = process.env, cwd = process.cwd() } = {}) {
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)) throw new SecretError('usage: starci app secret list | show <slug> | set <slug> | gen <slug> [--key NAME] [--env NAME] [--age RECIPIENT] [--bytes N] [--cwd DIR]');
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
- const repoRoot = path.resolve(cwd, opts.cwd ?? '.');
207
- const directory = secretsDirectory(repoRoot, opts.env);
208
- const key = opts.key ?? DEFAULT_KEY;
209
- if (verb === 'list') return listSecrets(opts, directory, repoRoot, stdout);
210
- if (opts.positional.length !== 1) throw new SecretError(`starci app secret ${verb} takes one secret name`);
211
- const file = fileOf(directory, opts.positional[0]);
212
- const seam = sops ?? defaultSops(env);
213
- if (verb === 'show') return showSecret(opts, file, seam, stdout);
214
- if (verb === 'set') return setSecretFromStdin(opts, file, key, seam, stdin, stdout);
215
- return generateSecret(opts, file, key, seam, random, stdout);
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
- if (error instanceof SecretError || error instanceof SyntaxError) { stderr(`starci app secret: ${error.message}\n`); return 2; }
218
- throw error;
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
+ }