@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.
- package/CHANGELOG.md +8 -0
- package/README.md +1 -1
- package/ci/cli.mjs +64 -0
- package/ci/github.mjs +41 -0
- package/ci/secret.mjs +28 -0
- package/ci/vars.mjs +33 -0
- package/ci/verify.mjs +36 -0
- package/db/backup-receipt.mjs +129 -0
- package/db/dump.mjs +66 -0
- package/db/migrate.mjs +8 -5
- package/db/post-cutover.mjs +4 -4
- package/db/reset.mjs +125 -0
- package/emit/contracts.mjs +2 -2
- package/emit/db-types.mjs +100 -7
- package/lint/run.mjs +30 -8
- package/package.json +2 -1
- package/release/ci-red.mjs +70 -0
- package/release/gate.mjs +53 -13
- package/release/index.mjs +8 -6
- 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 +505 -971
- package/runtime/knowledge/hfs/slots.yaml +13 -24
- package/runtime/modules/kernel/failure-codes.yaml +60 -22
- package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
- package/runtime/modules/models/runtimes.yaml +4 -6
- package/runtime/modules/schemas/application-stacks.schema.yaml +131 -1
- package/runtime/packages/cli/src/catalog.generated.mjs +286 -398
- 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/ci-declaration.mjs +102 -0
- 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 +10 -1
- package/runtime/scripts/hfs/rules/app-workflow-findings.mjs +10 -0
- package/runtime/scripts/hfs/rules/assets.mjs +1 -1
- package/runtime/scripts/hfs/rules/ci-custody.mjs +104 -0
- 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/deploy-workflow.mjs +6 -19
- package/runtime/scripts/hfs/rules/fe-build-args.mjs +142 -0
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +2 -2
- package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
- package/runtime/scripts/hfs/rules/supply-chain.mjs +40 -3
- package/runtime/scripts/hfs/rules/workflow-contexts.mjs +103 -0
- 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/runtime/scripts/lib/secret-patterns.mjs +2 -2
- package/scaffold/add-fe-overlay.mjs +0 -121
- package/scaffold/add-fe-page.mjs +0 -268
- package/scaffold/supabase-ports.mjs +1 -1
- package/src/main.mjs +17 -6
- package/stack/age-install.mjs +1 -1
- package/stack/cli.mjs +2 -2
- package/stack/context.mjs +14 -2
- package/stack/digest.mjs +1 -1
- package/stack/docker-transport.mjs +93 -15
- 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/release-store.mjs +12 -7
- package/stack/remote.mjs +16 -8
- package/stack/sealed.mjs +5 -5
- package/stack/ssh-session.mjs +178 -0
- package/sync/ci-commands.mjs +103 -1
- package/sync/format.mjs +1 -1
- package/sync/hygiene.mjs +1 -1
- package/sync/index.mjs +43 -18
- package/sync/quality-services.mjs +61 -0
- package/sync/readme-badges.mjs +44 -0
- package/sync/stack-images.mjs +31 -5
- package/templates/app/ci-workflows/github/workflows/ci.yml +26 -24
- package/templates/app/ci-workflows/github/workflows/images.yml +24 -2
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +8 -2
- package/templates/app/ci-workflows/install-steps.yml +21 -0
- package/templates/app/ci-workflows/sonar-steps.yml +3 -9
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +28 -35
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +2 -1
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +24 -2
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +8 -2
- package/templates/app/quality-config/codecov.yml +6 -6
- package/templates/app/quality-config/sonar-project.properties +1 -0
- package/templates/app/readme-badges/README.md +1 -0
- package/templates/app/skeleton/.github/dependabot.yml +1 -14
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +61 -3
- package/templates/app/skeleton/README.md +6 -0
- package/templates/app/skeleton-lite/.github/dependabot.yml +1 -14
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +57 -3
- package/templates/app/skeleton-lite/README.md +6 -0
- package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +52 -1
- 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/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
|
|
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) {
|
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,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;
|
|
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
|
-
|
|
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
|
|
3
|
-
// is run with a private HOME that holds
|
|
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 ${
|
|
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(
|
|
35
|
-
|
|
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 (
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
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
|
}
|