@starci/hfs 4.4.0 → 4.4.1

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 (69) hide show
  1. package/CHANGELOG.md +3 -0
  2. package/README.md +1 -1
  3. package/db/migrate.mjs +4 -4
  4. package/db/post-cutover.mjs +4 -4
  5. package/emit/contracts.mjs +2 -2
  6. package/emit/db-types.mjs +2 -2
  7. package/lint/run.mjs +2 -2
  8. package/package.json +1 -1
  9. package/release/gate.mjs +4 -4
  10. package/release/index.mjs +2 -2
  11. package/release/push.mjs +1 -1
  12. package/report/sonar.mjs +1 -1
  13. package/runtime/config.example.yaml +7 -6
  14. package/runtime/engine/config.mjs +10 -18
  15. package/runtime/engine/removed-vocabulary.mjs +0 -2
  16. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  17. package/runtime/knowledge/hfs/rules.yaml +410 -967
  18. package/runtime/knowledge/hfs/slots.yaml +13 -24
  19. package/runtime/modules/kernel/failure-codes.yaml +0 -22
  20. package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
  21. package/runtime/modules/models/runtimes.yaml +4 -6
  22. package/runtime/packages/cli/src/catalog.generated.mjs +46 -381
  23. package/runtime/scripts/api/git/lib.mjs +3 -3
  24. package/runtime/scripts/api/sops/lib.mjs +1 -1
  25. package/runtime/scripts/hfs/app-wiring.mjs +2 -2
  26. package/runtime/scripts/hfs/architecture/config.mjs +1 -1
  27. package/runtime/scripts/hfs/architecture/index.mjs +1 -1
  28. package/runtime/scripts/hfs/architecture/supabase.mjs +1 -1
  29. package/runtime/scripts/hfs/architecture/tiers.mjs +1 -1
  30. package/runtime/scripts/hfs/architecture.mjs +1 -1
  31. package/runtime/scripts/hfs/coverage-from.mjs +1 -1
  32. package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
  33. package/runtime/scripts/hfs/example-secrets.mjs +1 -1
  34. package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
  35. package/runtime/scripts/hfs/param-names.mjs +1 -1
  36. package/runtime/scripts/hfs/rule-catalog.mjs +3 -1
  37. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  38. package/runtime/scripts/hfs/rules/assets.mjs +1 -1
  39. package/runtime/scripts/hfs/rules/cli.mjs +1 -1
  40. package/runtime/scripts/hfs/rules/contract-compat.mjs +1 -1
  41. package/runtime/scripts/hfs/rules/contract.mjs +1 -1
  42. package/runtime/scripts/hfs/rules/frontend-tree.mjs +2 -2
  43. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
  44. package/runtime/scripts/hfs/runtime-rules/workflow-source.mjs +2 -2
  45. package/runtime/scripts/hfs/slots.mjs +2 -2
  46. package/runtime/scripts/hfs/typescript-programs.mjs +0 -8
  47. package/runtime/scripts/lib/env.mjs +2 -2
  48. package/runtime/scripts/lib/git.mjs +1 -1
  49. package/runtime/scripts/lib/i18n.mjs +0 -2
  50. package/runtime/scripts/lib/language.mjs +1 -1
  51. package/runtime/scripts/lib/roots.mjs +2 -4
  52. package/scaffold/add-fe-overlay.mjs +0 -121
  53. package/scaffold/add-fe-page.mjs +0 -268
  54. package/scaffold/supabase-ports.mjs +1 -1
  55. package/stack/age-install.mjs +1 -1
  56. package/stack/cli.mjs +2 -2
  57. package/stack/context.mjs +1 -1
  58. package/stack/digest.mjs +1 -1
  59. package/stack/edge-alias.mjs +1 -1
  60. package/stack/image-scan.mjs +1 -1
  61. package/stack/ingress-create.mjs +2 -2
  62. package/stack/ingress.mjs +3 -3
  63. package/stack/remote.mjs +2 -2
  64. package/stack/sealed.mjs +5 -5
  65. package/sync/format.mjs +1 -1
  66. package/sync/hygiene.mjs +1 -1
  67. package/sync/index.mjs +2 -2
  68. package/sync/stack-images.mjs +2 -2
  69. package/upgrade/index.mjs +1 -1
@@ -1,269 +1 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { loadSlotManifest, readRepoDeclaration } from "../runtime/scripts/hfs/slots.mjs";
4
- import { ScaffoldError, pascalOf } from "./service.mjs";
5
1
 
6
- const NAME = /^[a-z][a-z0-9-]*$/;
7
- const RESERVED = /^(?:con|prn|aux|nul|com[0-9]|lpt[0-9])$/i;
8
- const jsonText = (value) => `${JSON.stringify(value, null, 2)}\n`;
9
- const objectCatalog = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
10
-
11
- /** Validate a CLI name before it is used in a path, symbol or catalog key. */
12
- export function feName(value, label = "name") {
13
- if (typeof value !== "string" || !NAME.test(value) || value.split("-").includes("") || RESERVED.test(value)) {
14
- throw new ScaffoldError("HFS_ADD_NAME_INVALID", `${label} must be kebab-case, without a path or a reserved filename`);
15
- }
16
- return value;
17
- }
18
-
19
- /** An app-relative target whose existing ancestors are real directories, never symlinks. */
20
- function feTarget(root, relative) {
21
- const target = path.resolve(root, relative);
22
- const inside = path.relative(root, target);
23
- if (!inside || inside.startsWith("..") || path.isAbsolute(inside)) {
24
- throw new ScaffoldError("HFS_ADD_PATH_UNSAFE", `${relative} must stay inside the app root`);
25
- }
26
- let at = root;
27
- const pieces = inside.split(path.sep);
28
- for (let index = 0; index < pieces.length; index += 1) {
29
- at = path.join(at, pieces[index]);
30
- const stat = fs.lstatSync(at, { throwIfNoEntry: false });
31
- if (!stat) continue;
32
- if (stat.isSymbolicLink() || (index < pieces.length - 1 && !stat.isDirectory())) {
33
- throw new ScaffoldError("HFS_ADD_PATH_UNSAFE", `${relative} has a symlink or a non-directory ancestor`);
34
- }
35
- }
36
- return target;
37
- }
38
-
39
- /** Resolve the declared Next app; a multi-app repository must select one explicitly. */
40
- export function readFeContext({ root, app }) {
41
- const repoRoot = path.resolve(root);
42
- const stat = fs.lstatSync(repoRoot, { throwIfNoEntry: false });
43
- if (!stat?.isDirectory() || stat.isSymbolicLink()) {
44
- throw new ScaffoldError("HFS_ADD_NOT_AN_APP", "add page and overlay run at a real app root directory");
45
- }
46
- const declaration = feTarget(repoRoot, "hfs.json");
47
- const packageFile = feTarget(repoRoot, "package.json");
48
- if (!fs.existsSync(declaration) || !fs.existsSync(packageFile)) {
49
- throw new ScaffoldError("HFS_ADD_NOT_AN_APP", "add page and overlay need hfs.json and package.json at the app root");
50
- }
51
- const repo = readRepoDeclaration(loadSlotManifest(), repoRoot);
52
- const apps = repo.sides?.fe?.apps.filter((candidate) => candidate.kind === "next") ?? [];
53
- if (!apps.length) throw new ScaffoldError("HFS_ADD_NO_FRONT_END", "this app has no declared Next front end");
54
- if (app !== undefined) feName(app, "app");
55
- if (app === undefined && apps.length !== 1) {
56
- throw new ScaffoldError("HFS_ADD_OPTION_MISSING", "add page and overlay need --app when several Next apps are declared");
57
- }
58
- const selected = apps.find((candidate) => candidate.name === (app ?? apps[0].name));
59
- if (!selected) throw new ScaffoldError("HFS_ADD_APP_UNKNOWN", `front-end app ${app} is not declared`);
60
- feName(selected.name, "declared app");
61
- return { root: repoRoot, app: selected.name, repo, source: `fe/apps/${selected.name}/src` };
62
- }
63
-
64
- /** Read a required owner file only after checking every path ancestor. */
65
- export function readFeFile(context, relative) {
66
- const target = feTarget(context.root, relative);
67
- if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
68
- throw new ScaffoldError("HFS_ADD_WIRE_MISSING", `${relative} is required before this feature can be added`);
69
- }
70
- return fs.readFileSync(target, "utf8");
71
- }
72
-
73
- /** Refuse an existing owner, including a partially generated one. */
74
- export function requireNewFeOwner(context, relative) {
75
- if (fs.existsSync(feTarget(context.root, relative))) {
76
- throw new ScaffoldError("HFS_ADD_EXISTS", `${relative} already exists; add never replaces an existing owner`);
77
- }
78
- }
79
-
80
- function readCatalogs(context) {
81
- const directory = `${context.source}/modules/i18n/messages`;
82
- const target = feTarget(context.root, directory);
83
- if (!fs.existsSync(target)) throw new ScaffoldError("HFS_ADD_WIRE_MISSING", `${directory} is required`);
84
- if (!fs.statSync(target).isDirectory()) throw new ScaffoldError("HFS_ADD_I18N_INVALID", `${directory} must be a directory`);
85
- const files = fs.readdirSync(target).filter((file) => file.endsWith(".json")).sort();
86
- if (!["vi.json", "en.json"].every((file) => files.includes(file))) {
87
- throw new ScaffoldError("HFS_ADD_I18N_MISSING", `${directory} needs both vi.json and en.json`);
88
- }
89
- return files.map((file) => {
90
- const relative = `${directory}/${file}`;
91
- const before = readFeFile(context, relative);
92
- return { relative, before, locale: file.slice(0, -5), catalog: parseCatalog(before, relative) };
93
- });
94
- }
95
-
96
- function parseCatalog(before, relative) {
97
- let catalog;
98
- try { catalog = JSON.parse(before); } catch {
99
- throw new ScaffoldError("HFS_ADD_I18N_INVALID", `${relative} must contain valid JSON`);
100
- }
101
- if (!objectCatalog(catalog)) throw new ScaffoldError("HFS_ADD_I18N_INVALID", `${relative} must contain a catalog object`);
102
- return catalog;
103
- }
104
-
105
- function catalogNamespace(catalogs, app) {
106
- const first = catalogs[0].catalog;
107
- const keys = Object.keys(first);
108
- let namespace = keys.length === 1 ? keys[0] : app;
109
- if (!Object.hasOwn(first, namespace) && Object.hasOwn(first, "app")) namespace = "app";
110
- if (!catalogs.every(({ catalog }) => Object.hasOwn(catalog, namespace) && objectCatalog(catalog[namespace]))) {
111
- throw new ScaffoldError("HFS_ADD_I18N_INVALID", "the app catalogs need a shared object namespace");
112
- }
113
- return namespace;
114
- }
115
-
116
- function catalogCopy(locale, defaults, messages) {
117
- const copy = messages?.[locale] ?? defaults[locale];
118
- const required = Object.keys(defaults.en);
119
- if (!copy || !required.every((key) => typeof copy[key] === "string" && copy[key].trim())) {
120
- throw new ScaffoldError("HFS_ADD_I18N_INVALID", `provide the ${required.join(", ")} messages for locale ${locale}`);
121
- }
122
- return Object.fromEntries(required.map((key) => [key, copy[key]]));
123
- }
124
-
125
- /** Plan matching localized keys in every catalog without changing existing copy. */
126
- export function planFeCatalogs(context, { group, name, defaults, messages }) {
127
- const catalogs = readCatalogs(context);
128
- const namespace = catalogNamespace(catalogs, context.app);
129
- const updates = catalogs.map(({ catalog, locale, relative, before }) => {
130
- const owner = catalog[namespace];
131
- if (owner[group] !== undefined && !objectCatalog(owner[group])) {
132
- throw new ScaffoldError("HFS_ADD_I18N_INVALID", `${relative}: ${namespace}.${group} must be an object`);
133
- }
134
- owner[group] ??= {};
135
- if (Object.hasOwn(owner[group], name)) {
136
- throw new ScaffoldError("HFS_ADD_EXISTS", `${relative} already declares ${namespace}.${group}.${name}`);
137
- }
138
- owner[group][name] = catalogCopy(locale, defaults, messages);
139
- return { relative, before, body: jsonText(catalog) };
140
- });
141
- return { namespace: `${namespace}.${group}.${name}`, updates };
142
- }
143
-
144
- function createParent(root, target, made) {
145
- const missing = [];
146
- for (let at = path.dirname(target); at !== root && !fs.existsSync(at); at = path.dirname(at)) missing.push(at);
147
- for (const directory of missing.reverse()) {
148
- fs.mkdirSync(directory);
149
- made.push(directory);
150
- }
151
- }
152
-
153
- function writeCreated(target, body, written) {
154
- const descriptor = fs.openSync(target, "wx");
155
- written.push(target);
156
- try { fs.writeFileSync(descriptor, body); } finally { fs.closeSync(descriptor); }
157
- }
158
-
159
- /** Execute a fully checked plan and restore updated owners if a write fails. */
160
- export function writeFePlan(context, created, updates) {
161
- for (const file of created) requireNewFeOwner(context, file.relative);
162
- for (const file of updates) {
163
- if (readFeFile(context, file.relative) !== file.before) {
164
- throw new ScaffoldError("HFS_ADD_WIRE_INVALID", `${file.relative} changed while planning the feature`);
165
- }
166
- }
167
- const written = [];
168
- const updated = [];
169
- const directories = [];
170
- try {
171
- for (const file of created) {
172
- const target = feTarget(context.root, file.relative);
173
- createParent(context.root, target, directories);
174
- writeCreated(target, file.body, written);
175
- }
176
- for (const file of updates) {
177
- updated.push(file);
178
- fs.writeFileSync(feTarget(context.root, file.relative), file.body);
179
- }
180
- } catch (error) {
181
- for (const file of updated.reverse()) fs.writeFileSync(feTarget(context.root, file.relative), file.before);
182
- for (const file of written.reverse()) fs.rmSync(file);
183
- for (const directory of directories.reverse()) fs.rmdirSync(directory);
184
- throw error;
185
- }
186
- return { created: created.map((file) => file.relative), updated: updates.map((file) => file.relative) };
187
- }
188
-
189
- function pageSources({ name, namespace, symbol, routeSymbol }) {
190
- const metadata = `${name.split("-").map((word, index) => index ? pascalOf(word) : word).join("")}Metadata`;
191
- return {
192
- metadata,
193
- entry: `import type { Metadata } from "next"
194
- import { getTranslations } from "next-intl/server"
195
- import { ${routeSymbol} } from "@/modules/routes"
196
- import { ${symbol}Base } from "./component"
197
-
198
- /** The localized document title and canonical destination of this page. */
199
- export const ${metadata} = async (): Promise<Metadata> => {
200
- const t = await getTranslations(${JSON.stringify(namespace)})
201
- return { title: t("title"), alternates: { canonical: ${routeSymbol} } }
202
- }
203
-
204
- /** Resolve the requested locale's copy before drawing this server page. */
205
- export const ${symbol} = async () => {
206
- const t = await getTranslations(${JSON.stringify(namespace)})
207
- return <${symbol}Base props={{ title: t("title") }} />
208
- }
209
- `,
210
- component: `import { Heading } from "@starci/grammar/common"
211
-
212
- /** The localized words displayed by this page. */
213
- export interface ${symbol}BaseProps {
214
- readonly props: { readonly title: string }
215
- }
216
-
217
- /** Draw the page using the settled copy supplied by its server owner. */
218
- export const ${symbol}Base = (props: ${symbol}BaseProps) => (
219
- <Heading level={1}>{props.props.title}</Heading>
220
- )
221
- `,
222
- };
223
- }
224
-
225
- function pageRoute(symbol, metadata) {
226
- return `import { ${symbol} } from "@/features/pages/${symbol}"
227
-
228
- /** The locale-aware metadata of this page. */
229
- export { ${metadata} as generateMetadata } from "@/features/pages/${symbol}"
230
-
231
- /** Mount the page's public server owner. */
232
- const Page = () => <${symbol} />
233
-
234
- export default Page
235
- `;
236
- }
237
-
238
- /** Add a server page, its thin localized route and matching vi/en catalog entries. */
239
- export function addFePage({ root, app, name, route = name, messages }) {
240
- feName(name, "page name");
241
- if (typeof route !== "string" || !route) throw new ScaffoldError("HFS_ADD_OPTION_INVALID", "--route needs one or more kebab-case path segments");
242
- for (const segment of route.split("/")) feName(segment, "route segment");
243
- const context = readFeContext({ root, app });
244
- const symbol = `${pascalOf(name)}Page`;
245
- const owner = `${context.source}/features/pages/${symbol}`;
246
- requireNewFeOwner(context, owner);
247
- const routeFile = `${context.source}/app/[locale]/${route}/page.tsx`;
248
- requireNewFeOwner(context, routeFile);
249
- const catalogs = planFeCatalogs(context, {
250
- group: "pages", name, messages,
251
- defaults: { vi: { title: "Trang m\u1edbi" }, en: { title: "New page" } },
252
- });
253
- const routeSymbol = `${name.replaceAll("-", "_").toUpperCase()}_PAGE_ROUTE`;
254
- const routesFile = `${context.source}/modules/routes/index.ts`;
255
- const before = readFeFile(context, routesFile);
256
- if (before.split(/[^A-Za-z0-9_]/).includes(routeSymbol)) {
257
- throw new ScaffoldError("HFS_ADD_EXISTS", `${routesFile} already declares ${routeSymbol}`);
258
- }
259
- const sources = pageSources({ name, namespace: catalogs.namespace, symbol, routeSymbol });
260
- const created = [
261
- { relative: `${owner}/index.tsx`, body: sources.entry },
262
- { relative: `${owner}/component.tsx`, body: sources.component },
263
- { relative: routeFile, body: pageRoute(symbol, sources.metadata) },
264
- ];
265
- const destination = `/${route}`;
266
- const routeBody = `${before.trimEnd()}\n\n/** The canonical destination of the ${symbol} owner. */\nexport const ${routeSymbol} = ${JSON.stringify(destination)} as const\n`;
267
- const updates = [...catalogs.updates, { relative: routesFile, before, body: routeBody }];
268
- return { ...writeFePlan(context, created, updates), registered: { app: context.app, page: symbol, route: destination } };
269
- }
@@ -17,7 +17,7 @@ export const SUPABASE_PORT_VARIABLES = Object.freeze(Object.keys(OFFSETS).map((k
17
17
  const hash = (text) => [...text].reduce((value, char) => Math.imul(value ^ char.charCodeAt(0), 16777619) >>> 0, 2166136261);
18
18
 
19
19
  /** The first port of the project's block. */
20
- export const supabasePortBase = (project) => RANGE_START + (hash(String(project)) % BLOCK_COUNT) * BLOCK_SIZE;
20
+ const supabasePortBase = (project) => RANGE_START + (hash(String(project)) % BLOCK_COUNT) * BLOCK_SIZE;
21
21
 
22
22
  /** { supabasePortApi: '41231', ... }: the variables a skeleton file fills. */
23
23
  export function supabasePortVars(project) {
@@ -5,7 +5,7 @@ import { firstLine } from './context.mjs';
5
5
  const AGE_VERSION = 'v1.2.1';
6
6
  const AGE_URL = `https://github.com/FiloSottile/age/releases/download/${AGE_VERSION}/age-${AGE_VERSION}-linux-amd64.tar.gz`;
7
7
  /** sha256 of the official linux/amd64 archive of AGE_VERSION. */
8
- export const AGE_SHA256 = '7df45a6cc87d4da11cc03a539a7470c15b1041ab2b396af088fe9990f7c79d50';
8
+ const AGE_SHA256 = '7df45a6cc87d4da11cc03a539a7470c15b1041ab2b396af088fe9990f7c79d50';
9
9
  const INSTALL = { dnf: 'sudo -n dnf install -y age', yum: 'sudo -n yum install -y age', apk: 'sudo -n apk add age', 'apt-get': 'sudo -n apt-get install -y age' };
10
10
 
11
11
  const STATIC_INSTALL = [
package/stack/cli.mjs CHANGED
@@ -18,7 +18,7 @@ const VALUE = new Set(['--env', '--image', '--source-sha', '--previous', '--out'
18
18
  const REPEAT = new Set(['--image', '--keep', '--extra-compose', '--project']);
19
19
 
20
20
  /** Splits argv into { flags, positional }; an unknown flag or a flag without its value is a refusal. */
21
- export function parseFlags(argv) {
21
+ function parseFlags(argv) {
22
22
  const flags = {};
23
23
  const positional = [];
24
24
  for (let index = 0; index < argv.length; index += 1) {
@@ -37,7 +37,7 @@ export function parseFlags(argv) {
37
37
  }
38
38
 
39
39
  /** `--image api=ghcr.io/o/x@sha256:...` (repeatable or comma separated) -> { api: 'ghcr.io/o/x@sha256:...' }. */
40
- export function parseImages(values = []) {
40
+ function parseImages(values = []) {
41
41
  const out = {};
42
42
  for (const item of values.flatMap((value) => value.split(','))) {
43
43
  const at = item.indexOf('=');
package/stack/context.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // context.mjs - the seams every stack module runs through: spawning tools, sleeping, the clock and the output.
2
- // Production uses the real implementations; the specs inject fakes (tests/helpers/stack-fakes.mjs).
2
+ // Production uses the real implementations; a spec injects fakes.
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import { defaultSops } from '../runtime/scripts/hfs/secret-sops.mjs';
5
5
 
package/stack/digest.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // digest.mjs - image reference and release identifier guards (the all-zero placeholder digest is never a real image).
2
2
  /** A registry image reference pinned by a real sha256 digest; the all-zero placeholder digest is refused. */
3
- export const DIGEST_REF = /^[a-z0-9./_-]+(?::[A-Za-z0-9._-]+)?@sha256:(?!0{64}$)[0-9a-f]{64}$/u;
3
+ const DIGEST_REF = /^[a-z0-9./_-]+(?::[A-Za-z0-9._-]+)?@sha256:(?!0{64}$)[0-9a-f]{64}$/u;
4
4
 
5
5
  /** A release identifier: the first 16 hex characters of the release digest. */
6
6
  export const RELEASE_ID = /^[0-9a-f]{16}$/u;
@@ -22,7 +22,7 @@ const sameSet = (left, right) => [...new Set(left)].sort().join('\n') === [...ne
22
22
  const show = (aliases) => `[${aliases.join(', ')}]`;
23
23
 
24
24
  /** [{ service, network, aliases }]: every service attached to an `external: true` network of the stack, with the aliases it declares. */
25
- export function edgeAttachments(bytes) {
25
+ function edgeAttachments(bytes) {
26
26
  const model = parseYaml(bytes) ?? {};
27
27
  const external = new Map(Object.entries(model.networks ?? {}).filter(([, definition]) => definition?.external === true).map(([key, definition]) => [key, String(definition.name ?? key)]));
28
28
  return Object.entries(model.services ?? {}).flatMap(([service, entry]) => Object.entries(networkMap(entry?.networks))
@@ -51,7 +51,7 @@ const paxPath = (body) => body.toString('utf8').split('\n').map((record) => /^\d
51
51
  * Walks a tar file by header: calls visit({ name, type, size, read }) for every entry (`read()` returns the content, called only for files worth
52
52
  * reading). Handles ustar prefixes, GNU long names (L) and PAX paths (x). Content is never loaded unless the visitor asks for it.
53
53
  */
54
- export function walkTar(file, visit) {
54
+ function walkTar(file, visit) {
55
55
  const fd = fs.openSync(file, 'r');
56
56
  try {
57
57
  const header = Buffer.alloc(BLOCK);
@@ -26,7 +26,7 @@ function readLegacy(run, ingress) {
26
26
  }
27
27
 
28
28
  /** The stack model (compose spec as JSON, which is valid YAML). */
29
- export function stackModel(ingress, { args, dynamicTarget, acmeTarget }) {
29
+ function stackModel(ingress, { args, dynamicTarget, acmeTarget }) {
30
30
  const ports = [80, 443].map((port) => ({ target: port, published: port, protocol: 'tcp', mode: 'host' }));
31
31
  return {
32
32
  services: {
@@ -54,7 +54,7 @@ export function stackModel(ingress, { args, dynamicTarget, acmeTarget }) {
54
54
  }
55
55
 
56
56
  /** The guard that no task can start from this model. */
57
- export function refuseRunning(model) {
57
+ function refuseRunning(model) {
58
58
  for (const [name, service] of Object.entries(model.services)) {
59
59
  if (service.deploy?.replicas !== 0) throw refusal('STACK_INGRESS_WOULD_START', `the ingress service ${name} would start a task; create deploys it with replicas 0 only`);
60
60
  }
package/stack/ingress.mjs CHANGED
@@ -44,7 +44,7 @@ const originOf = (url) => {
44
44
  // ---- the live and the declared routes -------------------------------------------------------------------------------------------
45
45
 
46
46
  /** Host keys of a proxy router rule: `a.example` for Host(...), `regexp:<pattern>` for HostRegexp(...). */
47
- export function hostsOfRule(rule) {
47
+ function hostsOfRule(rule) {
48
48
  const out = [];
49
49
  for (const [, kind, args] of String(rule).matchAll(MATCHER)) {
50
50
  for (const [, value] of args.matchAll(QUOTED)) out.push(kind === 'Host' ? value.toLowerCase() : `regexp:${value}`);
@@ -53,7 +53,7 @@ export function hostsOfRule(rule) {
53
53
  }
54
54
 
55
55
  /** { routes: [{ host, targets }], middlewares: [name] } of one parsed file-provider dynamic document (any shape is tolerated). */
56
- export function routesOfDocument(doc) {
56
+ function routesOfDocument(doc) {
57
57
  const http = asMap(asMap(doc).http);
58
58
  const services = asMap(http.services);
59
59
  const targetsOf = (name) => {
@@ -491,7 +491,7 @@ const rehearsalVolume = (ingress) => `${ingress.acmeVolume}-rehearsal`;
491
491
  const classOf = (code) => Math.floor(code / 100);
492
492
 
493
493
  /** The static arguments of the ingress service with the ACME http challenge moved to an unused internal entrypoint. */
494
- export function rehearsalArgs(args) {
494
+ function rehearsalArgs(args) {
495
495
  const quiet = args.map((arg) => arg.replace(CHALLENGE_ARG, `$1=${UNUSED_ENTRYPOINT}`));
496
496
  return [...quiet, `--entrypoints.${UNUSED_ENTRYPOINT}.address=127.0.0.1:1`];
497
497
  }
package/stack/remote.mjs CHANGED
@@ -123,7 +123,7 @@ function sealedRunner(ctx, config) {
123
123
  }
124
124
 
125
125
  /** The ssh runner of one invocation (see the top of the file). */
126
- export function sshRunner(ctx, config, { sshCmd = null } = {}) {
126
+ function sshRunner(ctx, config, { sshCmd = null } = {}) {
127
127
  if (sshCmd) return makeRunner(ctx, sshCommandArgv(sshCmd));
128
128
  if (config.remote.sealed && ctx.env.STARCI_STACK_SEALED_SSH === '1') return sealedRunner(ctx, config);
129
129
  return makeRunner(ctx, ['ssh', ...SSH_OPTIONS, config.remote.host]);
@@ -133,7 +133,7 @@ export function sshRunner(ctx, config, { sshCmd = null } = {}) {
133
133
  * The context to open the runner with. On an operator machine (not CI, no --ssh-cmd, the sealed members exist, no explicit
134
134
  * STARCI_STACK_SEALED_SSH) the sealed session is selected; otherwise the context is returned unchanged.
135
135
  */
136
- export function withOwnerTransport(ctx, config, { sshCmd = null } = {}) {
136
+ function withOwnerTransport(ctx, config, { sshCmd = null } = {}) {
137
137
  const { sealed } = config.remote;
138
138
  if (sshCmd || !sealed || ctx.env.GITHUB_ACTIONS === 'true' || ctx.env.STARCI_STACK_SEALED_SSH !== undefined) return ctx;
139
139
  const present = [sealed.ops, sealed.sshKey, sealed.knownHosts].every((slug) => fs.existsSync(path.join(config.secretsDir, `${slug}.enc`)));
package/stack/sealed.mjs CHANGED
@@ -19,16 +19,16 @@ const CR = String.fromCodePoint(13);
19
19
  const LF = String.fromCodePoint(10);
20
20
 
21
21
  /** True for content that is not text: a NUL byte in the first 8 KiB. Binary files are never rewritten. */
22
- export const isBinary = (bytes) => bytes.subarray(0, 8192).includes(0);
22
+ const isBinary = (bytes) => bytes.subarray(0, 8192).includes(0);
23
23
 
24
24
  /** Every CRLF of a string becomes LF. */
25
- export const lfText = (text) => text.replaceAll(CR + LF, LF);
25
+ const lfText = (text) => text.replaceAll(CR + LF, LF);
26
26
 
27
27
  /**
28
28
  * Line endings of a text file as the services need them: every CRLF becomes LF (a script rendered from a Windows checkout failed with
29
29
  * `set: Illegal option -`). Binary content is returned untouched. Takes and returns a Buffer.
30
30
  */
31
- export function lfBytes(bytes) {
31
+ function lfBytes(bytes) {
32
32
  if (isBinary(bytes) || !bytes.includes(13)) return bytes;
33
33
  return Buffer.from(lfText(bytes.toString('latin1')), 'latin1');
34
34
  }
@@ -37,14 +37,14 @@ export function lfBytes(bytes) {
37
37
  export const configBytes = (file) => lfBytes(fs.readFileSync(file));
38
38
 
39
39
  /** The key that makes the version suffix of a secret name unguessable from the name alone: a local fixture key or the age identity. */
40
- export function versionKeyOf(ctx) {
40
+ function versionKeyOf(ctx) {
41
41
  const key = ctx.env.STARCI_SECRET_VERSION_KEY || ctx.env.SOPS_AGE_KEY;
42
42
  if (!key) throw refusal('STACK_VERSION_KEY_MISSING', 'STARCI_SECRET_VERSION_KEY (a local fixture) or SOPS_AGE_KEY is needed to version the secret names');
43
43
  return key;
44
44
  }
45
45
 
46
46
  /** `<stack>-<key>-v<10 hex of HMAC(versionKey, key NUL value)>`: a changed value is a new name, an unchanged one keeps its name. */
47
- export function secretRuntimeName(stack, key, value, versionKey) {
47
+ function secretRuntimeName(stack, key, value, versionKey) {
48
48
  const version = createHmac('sha256', versionKey).update(`${key}\0${value}`).digest('hex').slice(0, 10);
49
49
  const name = `${stack}-${key.toLowerCase().replaceAll('_', '-')}-v${version}`;
50
50
  if (name.length > MAX_NAME) throw refusal('STACK_SECRET_NAME_TOO_LONG', `the Swarm secret name for ${key} would exceed ${MAX_NAME} characters`);
package/sync/format.mjs CHANGED
@@ -13,7 +13,7 @@ const IGNORE_FILE = '.prettierignore';
13
13
  const LOCKFILES = new Set(['package-lock.json']);
14
14
 
15
15
  /** The repository's own prettier, or a SyncError when it is not installed. */
16
- export function loadPrettier(repoRoot) {
16
+ function loadPrettier(repoRoot) {
17
17
  const require = createRequire(path.join(repoRoot, 'package.json'));
18
18
  try {
19
19
  return require('prettier');
package/sync/hygiene.mjs CHANGED
@@ -43,7 +43,7 @@ function ignoredAmong(cwd, files) {
43
43
  }
44
44
 
45
45
  /** Violations among `files` (repository-relative, '/'-separated): [{ file, code, message }]. `ignored` is a Set of ignored paths. */
46
- export function hygieneFindings(files, ignored) {
46
+ function hygieneFindings(files, ignored) {
47
47
  const findings = [];
48
48
  for (const file of files) {
49
49
  if (file.startsWith(WORK) && ignored.has(file)) {
package/sync/index.mjs CHANGED
@@ -37,8 +37,8 @@ const NODE_MAJOR = 22;
37
37
  const APP_BINARY = 'starci app';
38
38
  /** The scopes a target is rendered for: the app root, then each side. */
39
39
  export const SCOPES = Object.freeze([APP_SCOPE, ...SIDES]);
40
- export const BLOCK_BEGIN = '# >>> starci app sync (managed block; do not edit) >>>';
41
- export const BLOCK_END = '# <<< starci app sync <<<';
40
+ const BLOCK_BEGIN = '# >>> starci app sync (managed block; do not edit) >>>';
41
+ const BLOCK_END = '# <<< starci app sync <<<';
42
42
  const HEADER = scope => `# Generated by ${APP_BINARY} sync (${scope === APP_SCOPE ? 'app root' : scope + ' side'}). Do not edit: run "npx ${APP_BINARY} sync --write".`;
43
43
 
44
44
  export class SyncError extends Error {
@@ -32,7 +32,7 @@ function environmentImages(environment) {
32
32
  * The images of a parsed declaration, [{ service, app, buildArgs }] sorted by service: the union over its environments (the first
33
33
  * environment that names a service decides its app; a later one only adds build arguments). A declaration with no deploy block has none.
34
34
  */
35
- export function declaredStackImages(declaration) {
35
+ function declaredStackImages(declaration) {
36
36
  const byService = new Map();
37
37
  for (const environment of Object.values(record(declaration?.environments) ? declaration.environments : {})) {
38
38
  for (const image of environmentImages(environment)) {
@@ -44,7 +44,7 @@ export function declaredStackImages(declaration) {
44
44
  }
45
45
 
46
46
  /** The host variables (names only) the declared environments interpolate into `docker stack config`: the union of `deploy.passEnv`. */
47
- export function declaredPassEnv(declaration) {
47
+ function declaredPassEnv(declaration) {
48
48
  const names = Object.values(record(declaration?.environments) ? declaration.environments : {})
49
49
  .flatMap(environment => (Array.isArray(environment?.deploy?.passEnv) ? environment.deploy.passEnv : []))
50
50
  .filter(name => typeof name === 'string' && KEY.test(name));
package/upgrade/index.mjs CHANGED
@@ -413,7 +413,7 @@ async function model({ root, to, presets, manifest }) {
413
413
  * Plans or applies the lite -> full migration. Returns the ordered public plan (`{op,path,why}[]`) in both modes.
414
414
  * `presets` and `lock` are test seams for the full managed render and npm lock resolution.
415
415
  */
416
- export async function upgradeEdition({
416
+ async function upgradeEdition({
417
417
  root,
418
418
  to,
419
419
  plan = false,