@starci/hfs 4.4.0 → 4.5.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 (113) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +1 -1
  3. package/ci/cli.mjs +64 -0
  4. package/ci/github.mjs +41 -0
  5. package/ci/secret.mjs +28 -0
  6. package/ci/vars.mjs +33 -0
  7. package/ci/verify.mjs +36 -0
  8. package/db/backup-receipt.mjs +129 -0
  9. package/db/dump.mjs +66 -0
  10. package/db/migrate.mjs +8 -5
  11. package/db/post-cutover.mjs +4 -4
  12. package/db/reset.mjs +125 -0
  13. package/emit/contracts.mjs +2 -2
  14. package/emit/db-types.mjs +100 -7
  15. package/lint/run.mjs +30 -8
  16. package/package.json +2 -1
  17. package/release/ci-red.mjs +70 -0
  18. package/release/gate.mjs +53 -13
  19. package/release/index.mjs +8 -6
  20. package/release/push.mjs +1 -1
  21. package/report/sonar.mjs +1 -1
  22. package/runtime/config.example.yaml +7 -6
  23. package/runtime/engine/config.mjs +10 -18
  24. package/runtime/engine/removed-vocabulary.mjs +0 -2
  25. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  26. package/runtime/knowledge/hfs/rules.yaml +505 -971
  27. package/runtime/knowledge/hfs/slots.yaml +13 -24
  28. package/runtime/modules/kernel/failure-codes.yaml +60 -22
  29. package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
  30. package/runtime/modules/models/runtimes.yaml +4 -6
  31. package/runtime/modules/schemas/application-stacks.schema.yaml +131 -1
  32. package/runtime/packages/cli/src/catalog.generated.mjs +286 -398
  33. package/runtime/scripts/api/git/lib.mjs +3 -3
  34. package/runtime/scripts/api/sops/lib.mjs +1 -1
  35. package/runtime/scripts/hfs/app-wiring.mjs +2 -2
  36. package/runtime/scripts/hfs/architecture/config.mjs +1 -1
  37. package/runtime/scripts/hfs/architecture/index.mjs +1 -1
  38. package/runtime/scripts/hfs/architecture/supabase.mjs +1 -1
  39. package/runtime/scripts/hfs/architecture/tiers.mjs +1 -1
  40. package/runtime/scripts/hfs/architecture.mjs +1 -1
  41. package/runtime/scripts/hfs/ci-declaration.mjs +102 -0
  42. package/runtime/scripts/hfs/coverage-from.mjs +1 -1
  43. package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
  44. package/runtime/scripts/hfs/example-secrets.mjs +1 -1
  45. package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
  46. package/runtime/scripts/hfs/param-names.mjs +1 -1
  47. package/runtime/scripts/hfs/rule-catalog.mjs +3 -1
  48. package/runtime/scripts/hfs/rules/a15-findings.mjs +10 -1
  49. package/runtime/scripts/hfs/rules/app-workflow-findings.mjs +10 -0
  50. package/runtime/scripts/hfs/rules/assets.mjs +1 -1
  51. package/runtime/scripts/hfs/rules/ci-custody.mjs +104 -0
  52. package/runtime/scripts/hfs/rules/cli.mjs +1 -1
  53. package/runtime/scripts/hfs/rules/contract-compat.mjs +1 -1
  54. package/runtime/scripts/hfs/rules/contract.mjs +1 -1
  55. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +6 -19
  56. package/runtime/scripts/hfs/rules/fe-build-args.mjs +142 -0
  57. package/runtime/scripts/hfs/rules/frontend-tree.mjs +2 -2
  58. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
  59. package/runtime/scripts/hfs/rules/supply-chain.mjs +40 -3
  60. package/runtime/scripts/hfs/rules/workflow-contexts.mjs +103 -0
  61. package/runtime/scripts/hfs/runtime-rules/workflow-source.mjs +2 -2
  62. package/runtime/scripts/hfs/slots.mjs +2 -2
  63. package/runtime/scripts/hfs/typescript-programs.mjs +0 -8
  64. package/runtime/scripts/lib/env.mjs +2 -2
  65. package/runtime/scripts/lib/git.mjs +1 -1
  66. package/runtime/scripts/lib/i18n.mjs +0 -2
  67. package/runtime/scripts/lib/language.mjs +1 -1
  68. package/runtime/scripts/lib/roots.mjs +2 -4
  69. package/runtime/scripts/lib/secret-patterns.mjs +2 -2
  70. package/scaffold/add-fe-overlay.mjs +0 -121
  71. package/scaffold/add-fe-page.mjs +0 -268
  72. package/scaffold/supabase-ports.mjs +1 -1
  73. package/src/main.mjs +17 -6
  74. package/stack/age-install.mjs +1 -1
  75. package/stack/cli.mjs +2 -2
  76. package/stack/context.mjs +14 -2
  77. package/stack/digest.mjs +1 -1
  78. package/stack/docker-transport.mjs +93 -15
  79. package/stack/edge-alias.mjs +1 -1
  80. package/stack/image-scan.mjs +1 -1
  81. package/stack/ingress-create.mjs +2 -2
  82. package/stack/ingress.mjs +3 -3
  83. package/stack/release-store.mjs +12 -7
  84. package/stack/remote.mjs +16 -8
  85. package/stack/sealed.mjs +5 -5
  86. package/stack/ssh-session.mjs +178 -0
  87. package/sync/ci-commands.mjs +103 -1
  88. package/sync/format.mjs +1 -1
  89. package/sync/hygiene.mjs +1 -1
  90. package/sync/index.mjs +43 -18
  91. package/sync/quality-services.mjs +61 -0
  92. package/sync/readme-badges.mjs +44 -0
  93. package/sync/stack-images.mjs +31 -5
  94. package/templates/app/ci-workflows/github/workflows/ci.yml +26 -24
  95. package/templates/app/ci-workflows/github/workflows/images.yml +24 -2
  96. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +8 -2
  97. package/templates/app/ci-workflows/install-steps.yml +21 -0
  98. package/templates/app/ci-workflows/sonar-steps.yml +3 -9
  99. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +28 -35
  100. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +2 -1
  101. package/templates/app/ci-workflows-lite/github/workflows/images.yml +24 -2
  102. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +8 -2
  103. package/templates/app/quality-config/codecov.yml +6 -6
  104. package/templates/app/quality-config/sonar-project.properties +1 -0
  105. package/templates/app/readme-badges/README.md +1 -0
  106. package/templates/app/skeleton/.github/dependabot.yml +1 -14
  107. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +61 -3
  108. package/templates/app/skeleton/README.md +6 -0
  109. package/templates/app/skeleton-lite/.github/dependabot.yml +1 -14
  110. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +57 -3
  111. package/templates/app/skeleton-lite/README.md +6 -0
  112. package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +52 -1
  113. 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) {
package/src/main.mjs CHANGED
@@ -15,7 +15,7 @@
15
15
  // runs on those owners without clones and dead exports. No merge-base is a refusal
16
16
  // (exit 2), never a silent full pass. Exit 1 on any error-level finding. The root checks run once and
17
17
  // the side checks and the machine once per side, the side folder as their root; every path is app-relative.
18
- // starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--fix] [--format text|json] [--sonar <file>]
18
+ // starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--side be|fe] [--fix] [--format text|json] [--sonar <file>]
19
19
  // the ONE lint entry (npm run lint): ESLint per side with that side's canon config (per-file rules and
20
20
  // the project-graph rules), this check's findings, and stylelint over the fe side, as one starci/lint@1
21
21
  // report; --sonar writes the one Sonar import file. Exit 0 clean, 1 findings, 2 a tool could not run (lint/run.mjs).
@@ -43,6 +43,9 @@
43
43
  // starci app secret materialize <env> | list <env> | verify <env>
44
44
  // dotenv custody in .starcistacks, public age rule initialization, private KEY files outside Git;
45
45
  // values reach stdout only when get explicitly receives --reveal.
46
+ // starci app ci secret set <NAME> [--env E | --dependabot] [--repo owner/name] reads the value from stdin only.
47
+ // starci app ci var set <NAME> [--env E] [--value v] | var sync [--execute] | verify [--json]
48
+ // declared GitHub CI custody; verify compares names, scopes and public values.
46
49
  // starci app db baseline | restamp --map <restamp.yaml> | plan
47
50
  // preview the database cutover or compare its state with the repo; baseline and restamp mutate only with --apply.
48
51
  // starci app add <api|webhook|realtime|saga|job|reactor|queue|projection> <name> [--event <event> --from <service> --service <Class>=<module>] [--owner <service> --failed <event> --done <event>] [--connection <name>] [--cwd <dir>]
@@ -83,18 +86,22 @@ import { ACTIONS as STACK_ACTIONS, stackMain as deployStackMain } from '../stack
83
86
  import { releaseMain } from '../release/index.mjs';
84
87
  import { dbMigrateMain } from '../db/migrate.mjs';
85
88
  import { dbPostCutoverMain } from '../db/post-cutover.mjs';
89
+ import { dbDumpMain } from '../db/dump.mjs';
90
+ import { dbResetMain } from '../db/reset.mjs';
86
91
 
87
- export const VERBS = Object.freeze(['scaffold', 'add', 'lint', 'sync', 'check', 'upgrade', 'stack', 'release', 'explain', 'emit', 'new', 'secret', 'db', 'hygiene']);
88
- const DATABASE_VERBS = new Map([['baseline', dbBaselineMain], ['restamp', dbRestampMain], ['plan', dbPlanMain], ['migrate', dbMigrateMain], ['post-cutover', dbPostCutoverMain]]);
92
+ export const VERBS = Object.freeze(['scaffold', 'add', 'lint', 'sync', 'check', 'upgrade', 'stack', 'release', 'explain', 'emit', 'new', 'secret', 'ci', 'db', 'hygiene']);
93
+ const DATABASE_VERBS = new Map([['baseline', dbBaselineMain], ['restamp', dbRestampMain], ['plan', dbPlanMain], ['migrate', dbMigrateMain], ['post-cutover', dbPostCutoverMain], ['dump', dbDumpMain], ['reset', dbResetMain]]);
89
94
 
90
95
  const USAGE = `starci app scaffold <name> [--into <dir>] [--edition full|lite] [--registry-visibility public|private] [--cwd <dir>]
91
96
  starci app add <api|webhook|realtime|saga|job|reactor|queue|projection|cli|table|app> <name> [options] [--cwd <dir>]
92
- starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--fix] [--format text|json] [--sonar <file>]
97
+ starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--side be|fe] [--fix] [--format text|json] [--sonar <file>]
93
98
  starci app sync (--check | --write) [--cwd <dir>]
94
99
  starci app check [--cwd <dir>] [--json] [--fast] [--base <ref>] [--edition full] [--db-types] [--wiring] [--coverage-from <lcov>]
95
100
  starci app db baseline [--stamp <version>] [--local] [--apply] [--cwd <dir>]
96
101
  starci app db restamp --map <restamp.yaml> [--local] [--apply] [--cwd <dir>]
97
102
  starci app db plan [--local] [--json] [--cwd <dir>]
103
+ starci app db dump (--linked | --local | --url-env <NAME>) --out <dir> [--schema-only] [--json] [--cwd <dir>]
104
+ starci app db reset --linked --auth keep|drop --storage keep|drop --backup <receipt.json> [--execute] [--json] [--cwd <dir>]
98
105
  starci app db migrate [--execute] [--linked | --url-env <NAME>] [--json] [--cwd <dir>]
99
106
  starci app db post-cutover [--execute] [--green-at <ISO> | --owner-confirms-old-app-gone] [--linked | --url-env <NAME>] [--json] [--cwd <dir>]
100
107
  starci app release gate [--execute] [--source <ref>] [--tag-prefix <prefix>] [--json] [--cwd <dir>]
@@ -116,6 +123,10 @@ starci app secret remove <env> <slug> [--execute] [--force-nonempty] [--cwd <dir
116
123
  starci app secret materialize <env> [--cwd <dir>]
117
124
  starci app secret list <env> [--cwd <dir>]
118
125
  starci app secret verify <env> [--cwd <dir>]
126
+ starci app ci secret set <NAME> [--env E | --dependabot] [--repo owner/name] [--cwd <dir>]
127
+ starci app ci var set <NAME> [--env E] [--value v] [--repo owner/name] [--cwd <dir>]
128
+ starci app ci var sync [--execute] [--repo owner/name] [--cwd <dir>]
129
+ starci app ci verify [--json] [--repo owner/name] [--cwd <dir>]
119
130
  starci app hygiene [--cwd <dir>]
120
131
  `;
121
132
  const PER_CODE_LIMIT = 25;
@@ -318,7 +329,7 @@ async function installedStackMain(cwd) {
318
329
  function databaseMain(argv, io) {
319
330
  const [verb, ...rest] = argv;
320
331
  const dispatch = DATABASE_VERBS.get(verb);
321
- if (!dispatch) throw new Error('starci app db takes baseline, restamp, plan, migrate or post-cutover');
332
+ if (!dispatch) throw new Error('starci app db takes baseline, restamp, plan, migrate, post-cutover, dump or reset');
322
333
  return dispatch(rest, io);
323
334
  }
324
335
 
@@ -408,7 +419,7 @@ async function stackAppMain(argv, { cwd, edition, stackMain, stdout: out, stderr
408
419
  return run(argv, { cwd, out, err });
409
420
  }
410
421
 
411
- const DIRECT_VERBS = new Map([['lint', lintMain], ['secret', secretMain], ['db', databaseMain], ['upgrade', upgradeAppMain], ['stack', stackAppMain], ['release', releaseMain]]);
422
+ const DIRECT_VERBS = new Map([['ci', async (argv, io) => (await import('../ci/cli.mjs')).ciMain(argv, io)], ['lint', lintMain], ['secret', secretMain], ['db', databaseMain], ['upgrade', upgradeAppMain], ['stack', stackAppMain], ['release', releaseMain]]);
412
423
  const PARSED_VERBS = new Map([['check', checkMain], ['emit', emitMain], ['new', newMain], ['add', addMain], ['scaffold', scaffoldMain], ['explain', explainMain]]);
413
424
 
414
425
  function dispatchMain(verb, argv, io) {
@@ -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,11 +1,18 @@
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).
3
- import { spawnSync } from 'node:child_process';
2
+ // Production uses the real implementations; a spec injects fakes.
3
+ import { spawn, spawnSync } from 'node:child_process';
4
4
  import { defaultSops } from '../runtime/scripts/hfs/secret-sops.mjs';
5
5
 
6
6
  const MAX_BUFFER = 1 << 24;
7
7
 
8
8
  const realSpawn = (command, args, options = {}) => {
9
+ if (options.background) {
10
+ const childOptions = { ...options };
11
+ delete childOptions.background;
12
+ const child = spawn(command, args, { stdio: 'ignore', windowsHide: true, ...childOptions });
13
+ child.on('error', (error) => { child.spawnError = error; });
14
+ return child;
15
+ }
9
16
  const run = spawnSync(command, args, { encoding: 'utf8', maxBuffer: MAX_BUFFER, ...options });
10
17
  return { status: run.status, stdout: run.stdout ?? '', stderr: run.stderr ?? '', error: run.error };
11
18
  };
@@ -16,6 +23,8 @@ const realSleep = (ms) => new Promise((done) => { setTimeout(done, ms); });
16
23
  * The context of one stack invocation.
17
24
  * cwd, env the app root and the process environment
18
25
  * spawn (command, args, { input, env, cwd }) -> { status, stdout, stderr, error }; never a shell
26
+ * { background: true } -> ChildProcess, owned and killed by the command session
27
+ * platform POSIX control master or win32 loopback tunnel; sshPath optionally selects the POSIX SSH executable
19
28
  * sleep, now await sleep(ms); now() -> epoch ms
20
29
  * stdout, stderr text writers
21
30
  * fetch the HTTP seam of verify (url, init) -> Response
@@ -29,6 +38,9 @@ export function stackContext(io = {}) {
29
38
  cwd,
30
39
  env,
31
40
  spawn: io.spawn ?? realSpawn,
41
+ platform: io.platform ?? process.platform,
42
+ username: io.username,
43
+ sshPath: io.sshPath,
32
44
  sleep: io.sleep ?? realSleep,
33
45
  now: io.now ?? Date.now,
34
46
  stdout: io.stdout ?? ((text) => process.stdout.write(text)),
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;
@@ -1,15 +1,19 @@
1
1
  // docker-transport.mjs - lets the verbs that drive the docker CLI (deploy, converge, verify, rollback, prune) reach the manager from an OPERATOR
2
- // machine. In CI `connect` writes the ssh config and DOCKER_HOST; on an operator machine the key lives in the sealed members, so the docker CLI
3
- // is run with a private HOME that holds an ssh config for the sealed session and DOCKER_HOST=ssh://<alias>. Only `docker` children that did not get
2
+ // machine or CI. The command shares one POSIX control master (including remote scripts) or one Windows Docker socket tunnel. On an operator
3
+ // machine the key lives in the sealed members, so the docker CLI is run with a private HOME that holds the session's ssh config. Only children that did not get
4
4
  // an explicit environment are affected (the minimal environment of `docker stack config` stays exactly as the render built it). The key lives in
5
5
  // a private temp directory that is overwritten and removed when the verb ends; it is never in argv, a line or an error.
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { writePrivate } from './private-file.mjs';
9
9
  import { openSealedSession } from './remote.mjs';
10
+ import { controlSettings, createSshSession, sessionDirectory } from './ssh-session.mjs';
11
+ import { failure } from './errors.mjs';
10
12
 
11
13
  const ALIAS = 'stack-vps';
12
14
  const forward = (file) => file.replaceAll('\\', '/');
15
+ const configPath = (file) => '"' + forward(file).replaceAll('"', '\\"') + '"';
16
+ const controlLines = (controlPath) => controlSettings(configPath(controlPath)).map((setting) => ` ${setting.replace('=', ' ')}`);
13
17
 
14
18
  /** True when the operator's docker CLI has to be pointed at the manager by this module. */
15
19
  function needed(ctx, config, sshCmd) {
@@ -19,35 +23,109 @@ function needed(ctx, config, sshCmd) {
19
23
  return [sealed.ops, sealed.sshKey, sealed.knownHosts].every((slug) => fs.existsSync(path.join(config.secretsDir, `${slug}.enc`)));
20
24
  }
21
25
 
22
- function sshConfig(session) {
26
+ function sshConfig(session, controlPath, windows) {
23
27
  const at = session.account.lastIndexOf('@');
24
28
  return [
25
29
  `Host ${ALIAS}`, ` HostName ${session.account.slice(at + 1)}`, ` User ${session.account.slice(0, at)}`,
26
- ` IdentityFile ${forward(session.keyFile)}`, ' IdentitiesOnly yes', ` UserKnownHostsFile ${forward(session.knownFile)}`,
27
- ` StrictHostKeyChecking ${session.strict}`, ' BatchMode yes', ' ConnectTimeout 20', '',
30
+ ` IdentityFile ${configPath(session.keyFile)}`, ' IdentitiesOnly yes', ` UserKnownHostsFile ${configPath(session.knownFile)}`,
31
+ ` StrictHostKeyChecking ${session.strict}`, ' BatchMode yes', ' ConnectTimeout 20',
32
+ ...(windows ? [] : controlLines(controlPath)), '',
28
33
  ].join('\n');
29
34
  }
30
35
 
31
36
  /** The environment of a docker child: a private HOME with the ssh config, DOCKER_HOST, and the operator's docker config (registry logins) kept. */
32
- function dockerEnvironment(ctx, session) {
37
+ function dockerEnvironment(ctx, session, controlPath) {
33
38
  const home = path.join(session.dir, 'home');
34
- fs.mkdirSync(path.join(home, '.ssh'), { recursive: true });
35
- writePrivate(ctx, path.join(home, '.ssh', 'config'), sshConfig(session));
39
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
40
+ fs.chmodSync(home, 0o700);
41
+ fs.mkdirSync(path.join(home, '.ssh'), { recursive: true, mode: 0o700 });
42
+ writePrivate(ctx, path.join(home, '.ssh', 'config'), sshConfig(session, controlPath, (ctx.platform ?? process.platform) === 'win32'));
36
43
  const operatorHome = ctx.env.HOME ?? ctx.env.USERPROFILE ?? '';
37
44
  const dockerConfig = ctx.env.DOCKER_CONFIG ?? (operatorHome ? path.join(operatorHome, '.docker') : undefined);
38
45
  return { ...ctx.env, HOME: home, USERPROFILE: home, DOCKER_HOST: `ssh://${ALIAS}`, ...(dockerConfig ? { DOCKER_CONFIG: dockerConfig } : {}) };
39
46
  }
40
47
 
48
+ /** OpenSSH resolves its default config from the account database, so Docker needs a private PATH entry that supplies -F explicitly. */
49
+ function privateSshEnvironment(ctx, env, dir, controlPath) {
50
+ if ((ctx.platform ?? process.platform) === 'win32') {
51
+ const home = path.join(dir, 'home');
52
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
53
+ fs.chmodSync(home, 0o700);
54
+ const originalHome = env.HOME ?? env.USERPROFILE;
55
+ const dockerConfig = env.DOCKER_CONFIG ?? (originalHome ? path.join(originalHome, '.docker') : undefined);
56
+ return { env: { ...env, HOME: home, USERPROFILE: home, ...(dockerConfig ? { DOCKER_CONFIG: dockerConfig } : {}) }, executable: 'ssh' };
57
+ }
58
+ const originalHome = ctx.env.HOME;
59
+ const home = path.join(dir, 'home');
60
+ const config = path.join(home, '.ssh', 'config');
61
+ fs.mkdirSync(path.dirname(config), { recursive: true, mode: 0o700 });
62
+ if (!fs.existsSync(config)) {
63
+ const include = originalHome ? `Include ${configPath(path.join(originalHome, '.ssh', 'config'))}\n` : '';
64
+ writePrivate(ctx, config, ['Host *', ...controlLines(controlPath), 'Host *', include].join('\n'));
65
+ }
66
+ const executable = ctx.sshPath ?? findSsh(ctx.env.PATH);
67
+ const bin = path.join(home, 'bin');
68
+ fs.mkdirSync(bin, { mode: 0o700 });
69
+ const shim = path.join(bin, 'ssh');
70
+ writePrivate(ctx, shim, `#!/bin/sh\nexec ${posixQuote(executable)} -F ${posixQuote(config)} "$@"\n`);
71
+ fs.chmodSync(shim, 0o700);
72
+ const dockerConfig = env.DOCKER_CONFIG ?? (originalHome ? path.join(originalHome, '.docker') : undefined);
73
+ return { env: { ...env, HOME: home, PATH: `${bin}:${env.PATH ?? ctx.env.PATH ?? ''}`, ...(dockerConfig ? { DOCKER_CONFIG: dockerConfig } : {}) }, executable };
74
+ }
75
+
76
+ const posixQuote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
77
+
78
+ function findSsh(searchPath = '') {
79
+ const executable = searchPath.split(':').map((dir) => path.join(dir, 'ssh')).find((file) => fs.existsSync(file));
80
+ if (!executable) throw failure('STACK_SSH_SESSION', 'cannot locate the SSH executable');
81
+ return path.resolve(executable);
82
+ }
83
+
41
84
  /** Runs `body(ctx')` where ctx'.spawn points the docker CLI at the manager when this is an operator machine; otherwise `body(ctx)`. */
42
85
  export async function withDockerTransport(ctx, config, { sshCmd = null } = {}, body) {
43
- if (!needed(ctx, config, sshCmd)) return body(ctx);
44
- const session = openSealedSession(ctx, config);
45
- const env = dockerEnvironment(ctx, session);
46
- const spawn = (command, args, options = {}) => (command === 'docker' && options.env === undefined ? ctx.spawn(command, args, { ...options, env }) : ctx.spawn(command, args, options));
47
- for (const note of session.notes) ctx.stderr(`${note}\n`);
86
+ if (sshCmd) return body(ctx);
87
+ const sealed = needed(ctx, config, sshCmd);
88
+ const alias = sealed ? ALIAS : sshAlias(ctx.env.DOCKER_HOST);
89
+ if (!alias) return body(ctx);
90
+ const session = sealed ? openSealedSession(ctx, config) : null;
91
+ const dir = session?.dir ?? sessionDirectory();
92
+ let connection;
93
+ let cleaned = false;
94
+ function cleanup() {
95
+ if (cleaned) return;
96
+ cleaned = true;
97
+ if (session) session.cleanup();
98
+ else fs.rmSync(dir, { recursive: true, force: true });
99
+ }
48
100
  try {
49
- return await body(Object.create(ctx, { spawn: { value: spawn } }));
101
+ const controlPath = forward(path.join(dir, 'home', 'cm'));
102
+ const baseEnv = session ? dockerEnvironment(ctx, session, controlPath) : ctx.env;
103
+ const { env, executable } = privateSshEnvironment(ctx, baseEnv, dir, controlPath);
104
+ const configFile = path.join(env.HOME ?? '', '.ssh', 'config');
105
+ const options = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=20'];
106
+ const argv = session || executable !== 'ssh' ? [executable, ...options, '-F', configFile, '--', alias] : ['ssh', ...options, '--', alias];
107
+ connection = createSshSession(ctx, { argv, env, controlPath, cleanup });
108
+ const spawn = dockerSpawn(ctx, connection, env, alias);
109
+ for (const note of session?.notes ?? []) ctx.stderr(`${note}\n`);
110
+ return await body(Object.create(ctx, { spawn: { value: spawn }, stackSsh: { value: connection } }));
50
111
  } finally {
51
- session.cleanup();
112
+ try { await connection?.close(); } finally {
113
+ cleanup();
114
+ }
52
115
  }
53
116
  }
117
+
118
+ /** Only SSH Docker endpoints belong to this connection; TCP, unix and explicitly supplied child environments keep their behavior. */
119
+ function sshAlias(host) {
120
+ if (!host?.startsWith('ssh://')) return null;
121
+ const endpoint = new URL(host);
122
+ if (endpoint.port || endpoint.pathname && endpoint.pathname !== '/') return null;
123
+ return endpoint.username ? `${decodeURIComponent(endpoint.username)}@${endpoint.hostname}` : endpoint.hostname;
124
+ }
125
+
126
+ function dockerSpawn(ctx, connection, env, alias) {
127
+ return (command, args, options = {}) => {
128
+ if (command !== 'docker' || options.env !== undefined) return ctx.spawn(command, args, options);
129
+ return ctx.spawn(command, args, { ...options, env: { ...env, DOCKER_HOST: connection.dockerHost(alias) } });
130
+ };
131
+ }
@@ -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
  }