@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.
- package/CHANGELOG.md +3 -0
- package/README.md +1 -1
- package/db/migrate.mjs +4 -4
- package/db/post-cutover.mjs +4 -4
- package/emit/contracts.mjs +2 -2
- package/emit/db-types.mjs +2 -2
- package/lint/run.mjs +2 -2
- package/package.json +1 -1
- package/release/gate.mjs +4 -4
- package/release/index.mjs +2 -2
- package/release/push.mjs +1 -1
- package/report/sonar.mjs +1 -1
- package/runtime/config.example.yaml +7 -6
- package/runtime/engine/config.mjs +10 -18
- package/runtime/engine/removed-vocabulary.mjs +0 -2
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/runtime/knowledge/hfs/rules.yaml +410 -967
- package/runtime/knowledge/hfs/slots.yaml +13 -24
- package/runtime/modules/kernel/failure-codes.yaml +0 -22
- package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
- package/runtime/modules/models/runtimes.yaml +4 -6
- package/runtime/packages/cli/src/catalog.generated.mjs +46 -381
- package/runtime/scripts/api/git/lib.mjs +3 -3
- package/runtime/scripts/api/sops/lib.mjs +1 -1
- package/runtime/scripts/hfs/app-wiring.mjs +2 -2
- package/runtime/scripts/hfs/architecture/config.mjs +1 -1
- package/runtime/scripts/hfs/architecture/index.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase.mjs +1 -1
- package/runtime/scripts/hfs/architecture/tiers.mjs +1 -1
- package/runtime/scripts/hfs/architecture.mjs +1 -1
- package/runtime/scripts/hfs/coverage-from.mjs +1 -1
- package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
- package/runtime/scripts/hfs/example-secrets.mjs +1 -1
- package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
- package/runtime/scripts/hfs/param-names.mjs +1 -1
- package/runtime/scripts/hfs/rule-catalog.mjs +3 -1
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
- package/runtime/scripts/hfs/rules/assets.mjs +1 -1
- package/runtime/scripts/hfs/rules/cli.mjs +1 -1
- package/runtime/scripts/hfs/rules/contract-compat.mjs +1 -1
- package/runtime/scripts/hfs/rules/contract.mjs +1 -1
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +2 -2
- package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
- package/runtime/scripts/hfs/runtime-rules/workflow-source.mjs +2 -2
- package/runtime/scripts/hfs/slots.mjs +2 -2
- package/runtime/scripts/hfs/typescript-programs.mjs +0 -8
- package/runtime/scripts/lib/env.mjs +2 -2
- package/runtime/scripts/lib/git.mjs +1 -1
- package/runtime/scripts/lib/i18n.mjs +0 -2
- package/runtime/scripts/lib/language.mjs +1 -1
- package/runtime/scripts/lib/roots.mjs +2 -4
- package/scaffold/add-fe-overlay.mjs +0 -121
- package/scaffold/add-fe-page.mjs +0 -268
- package/scaffold/supabase-ports.mjs +1 -1
- package/stack/age-install.mjs +1 -1
- package/stack/cli.mjs +2 -2
- package/stack/context.mjs +1 -1
- package/stack/digest.mjs +1 -1
- package/stack/edge-alias.mjs +1 -1
- package/stack/image-scan.mjs +1 -1
- package/stack/ingress-create.mjs +2 -2
- package/stack/ingress.mjs +3 -3
- package/stack/remote.mjs +2 -2
- package/stack/sealed.mjs +5 -5
- package/sync/format.mjs +1 -1
- package/sync/hygiene.mjs +1 -1
- package/sync/index.mjs +2 -2
- package/sync/stack-images.mjs +2 -2
- package/upgrade/index.mjs +1 -1
package/scaffold/add-fe-page.mjs
CHANGED
|
@@ -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
|
-
|
|
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/stack/age-install.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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;
|
package/stack/edge-alias.mjs
CHANGED
|
@@ -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
|
-
|
|
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))
|
package/stack/image-scan.mjs
CHANGED
|
@@ -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
|
-
|
|
54
|
+
function walkTar(file, visit) {
|
|
55
55
|
const fd = fs.openSync(file, 'r');
|
|
56
56
|
try {
|
|
57
57
|
const header = Buffer.alloc(BLOCK);
|
package/stack/ingress-create.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
+
const isBinary = (bytes) => bytes.subarray(0, 8192).includes(0);
|
|
23
23
|
|
|
24
24
|
/** Every CRLF of a string becomes LF. */
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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 {
|
package/sync/stack-images.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
416
|
+
async function upgradeEdition({
|
|
417
417
|
root,
|
|
418
418
|
to,
|
|
419
419
|
plan = false,
|