@starci/hfs 4.2.16 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +7 -0
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/runtime/knowledge/hfs/rules.yaml +41 -13
- package/runtime/knowledge/hfs/slots.yaml +99 -43
- package/runtime/knowledge/patterns/be/api.yaml +64 -1
- package/runtime/knowledge/patterns/be/cli.yaml +23 -5
- package/runtime/knowledge/patterns/be/webhooks.yaml +187 -32
- package/runtime/knowledge/patterns/fe/folder.yaml +3 -0
- package/runtime/modules/kernel/failure-codes.yaml +15 -4
- package/runtime/scripts/api/git/cat-file.mjs +5 -0
- package/runtime/scripts/api/sops/resolve-sops.mjs +7 -0
- package/runtime/scripts/api/sops/seal.mjs +1 -1
- package/runtime/scripts/hfs/architecture/backend-app-files.mjs +110 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +20 -3
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
- package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
- package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
- package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
- package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
- package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +14 -9
- package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
- package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +21 -12
- package/runtime/scripts/hfs/architecture/register-once.mjs +13 -5
- package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
- package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +3 -6
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
- package/runtime/scripts/hfs/architecture/tiers.mjs +8 -1
- package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +1 -11
- package/runtime/scripts/hfs/check.mjs +18 -26
- package/runtime/scripts/hfs/manifest-shape.mjs +27 -2
- package/runtime/scripts/hfs/path-findings.mjs +32 -2
- package/runtime/scripts/hfs/rules/database-migrations.mjs +36 -35
- package/runtime/scripts/hfs/rules/database.mjs +2 -2
- package/runtime/scripts/hfs/rules/edition.mjs +5 -5
- package/runtime/scripts/hfs/rules/proof-commands.mjs +2 -0
- package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +128 -0
- package/runtime/scripts/hfs/rules/stacks.mjs +13 -8
- package/runtime/scripts/hfs/secret-document.mjs +97 -0
- package/runtime/scripts/hfs/secret-errors.mjs +20 -0
- package/runtime/scripts/hfs/secret-files.mjs +124 -0
- package/runtime/scripts/hfs/secret-init.mjs +192 -0
- package/runtime/scripts/hfs/secret-output.mjs +100 -0
- package/runtime/scripts/hfs/secret-sops.mjs +98 -0
- package/runtime/scripts/hfs/secret-store.mjs +161 -0
- package/runtime/scripts/hfs/secret.mjs +135 -195
- package/runtime/scripts/hfs/slot-imports.mjs +3 -1
- package/runtime/scripts/hfs/slot-required.mjs +13 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
- package/runtime/scripts/hfs/tree-findings.mjs +22 -0
- package/runtime/scripts/lib/roots.mjs +59 -0
- package/runtime/scripts/lib/swarm-stack.mjs +139 -0
- package/runtime/scripts/lib/ts-analysis.mjs +20 -0
- package/scaffold/add-cli-lite.mjs +10 -11
- package/scaffold/add-inbox-lite.mjs +15 -84
- package/scaffold/add-plan.mjs +183 -0
- package/scaffold/add-table.mjs +44 -5
- package/scaffold/add.mjs +33 -166
- package/scaffold/app.mjs +5 -5
- package/scaffold/image.mjs +2 -2
- package/src/main.mjs +12 -6
- package/sync/index.mjs +23 -16
- package/templates/app/gitignore +1 -0
- package/templates/app/gitignore-lite +1 -0
- package/templates/app/hooks/husky/pre-commit +7 -12
- package/templates/app/skeleton/.sops.yaml +1 -1
- package/templates/app/skeleton-lite/.sops.yaml +1 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +16 -0
- package/templates/app/skeleton-lite/.starciwork/workspace.yaml +9 -0
- package/templates/app/skeleton-lite/README.md +4 -2
- package/templates/app/starciwork.gitignore-lite +6 -0
- package/templates/be/inbox/database-client.spec.ts.tpl +15 -20
- package/templates/be/inbox/database-client.ts.tpl +7 -22
- package/templates/be/inbox/service.spec.ts.tpl +16 -20
- package/templates/be/inbox/service.ts.tpl +6 -10
- package/templates/be/inbox/sql.ts.tpl +2 -2
- package/templates/be/inbox/webhook-door.spec.ts.tpl +5 -5
- package/templates/be/inbox/webhook-door.ts.tpl +4 -3
- package/templates/be/patterns/webhooks/webhook.door.spec.ts.tpl +3 -3
- package/templates/be/patterns/webhooks/webhook.door.ts.tpl +3 -3
- package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +21 -0
- package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +1 -1
- package/templates/be/skeleton/apps/__app__/src/app.module.ts +7 -39
- package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +42 -0
- package/templates/be/skeleton/apps/__app__/src/main.ts +3 -9
- package/templates/be/skeleton/apps/cli/src/cli.env.ts +13 -0
- package/templates/be/skeleton/apps/cli/src/cli.options.ts +6 -8
- package/templates/be/skeleton/apps/cli/src/main.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +6 -6
- package/templates/be/skeleton-lite/apps/api/src/api.env.ts +25 -0
- package/templates/be/skeleton-lite/apps/api/src/api.options.ts +1 -1
- package/templates/be/skeleton-lite/apps/api/src/app.module.ts +6 -42
- package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +45 -0
- package/templates/be/skeleton-lite/apps/api/src/main.ts +4 -11
- package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +17 -0
- package/upgrade/file-state.mjs +68 -0
- package/upgrade/index.mjs +7 -56
- package/runtime/scripts/api/git/log.mjs +0 -5
- package/templates/app/skeleton-lite/.starciwork/shell/index.yaml +0 -55
package/scaffold/add-table.mjs
CHANGED
|
@@ -186,13 +186,48 @@ function wireImport(text, relative, { importLine, symbol, entry, kinds }) {
|
|
|
186
186
|
return `${lines.join("\n").replace(/\n+$/, "")}\n`;
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
-
/**
|
|
190
|
-
const
|
|
189
|
+
/** A line that opens the error kinds array of the errors registration. */
|
|
190
|
+
const KINDS_LINE = /^\s*kinds: \[$/;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The composition file of an api app that holds its error kinds array: the root `app.module.ts`, or else the composition group
|
|
194
|
+
* `compose/<area>.ts` of the same app that holds it (the platform group of a scaffolded app). Repository-relative.
|
|
195
|
+
*/
|
|
196
|
+
function kindsHomeOf(root, appRelative) {
|
|
197
|
+
const holdsKinds = (relative) => fs.readFileSync(at(root, relative), "utf8").replace(/\r\n/g, "\n").split("\n").some((line) => KINDS_LINE.test(line));
|
|
198
|
+
if (holdsKinds(appRelative)) return appRelative;
|
|
199
|
+
const composeRelative = `${path.posix.dirname(appRelative)}/compose`;
|
|
200
|
+
const groups = fs.existsSync(at(root, composeRelative)) ? fs.readdirSync(at(root, composeRelative)).filter((file) => file.endsWith(".ts")).sort(byCodeUnit) : [];
|
|
201
|
+
const home = groups.map((file) => `${composeRelative}/${file}`).find(holdsKinds);
|
|
202
|
+
if (!home) throw new ScaffoldError("HFS_ADD_WIRE_INVALID", `add table could not find the error kinds array in ${appRelative} or a group of its compose/`);
|
|
203
|
+
return home;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A composition group after `kinds` is listed first in its error kinds array and imported from `specifier`. Returns the new text. */
|
|
207
|
+
function wireKinds(text, relative, { kinds, specifier }) {
|
|
208
|
+
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
209
|
+
if (lines.some((line) => line.startsWith("import ") && new RegExp(`\\b${kinds}\\b`).test(line))) return text;
|
|
210
|
+
const lastImport = lines.reduce((last, line, index) => (line.startsWith("import ") ? index : last), -1);
|
|
211
|
+
const kindsAt = lines.findIndex((line) => KINDS_LINE.test(line));
|
|
212
|
+
if (lastImport < 0 || kindsAt < 0) throw new ScaffoldError("HFS_ADD_WIRE_INVALID", `add table could not find the error kinds array in ${relative}`);
|
|
213
|
+
const kindsIndent = /^(\s*)/.exec(lines[kindsAt])?.[1] ?? "";
|
|
214
|
+
lines.splice(kindsAt + 1, 0, `${kindsIndent} ${kinds},`);
|
|
215
|
+
lines.splice(lastImport + 1, 0, `import { ${kinds} } from "${specifier}"`);
|
|
216
|
+
return `${lines.join("\n").replace(/\n+$/, "")}\n`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The api app root after the table's domain module is registered: its module, and its error kinds too when the root holds the
|
|
221
|
+
* kinds array (`kindsInRoot`); otherwise wireKinds lists them in the composition group that does.
|
|
222
|
+
*/
|
|
223
|
+
const wireDomainModule = (text, relative, name, { kindsInRoot = true } = {}) =>
|
|
191
224
|
wireImport(text, relative, {
|
|
192
|
-
importLine:
|
|
225
|
+
importLine: kindsInRoot
|
|
226
|
+
? `import { ${snakeOf(name).toUpperCase()}_ERROR_KINDS, ${pascalOf(name)}Module } from "@modules/domain/${name}"`
|
|
227
|
+
: `import { ${pascalOf(name)}Module } from "@modules/domain/${name}"`,
|
|
193
228
|
symbol: `${pascalOf(name)}Module`,
|
|
194
229
|
entry: `${pascalOf(name)}Module.register({ isGlobal: true })`,
|
|
195
|
-
kinds: `${snakeOf(name).toUpperCase()}_ERROR_KINDS
|
|
230
|
+
...(kindsInRoot ? { kinds: `${snakeOf(name).toUpperCase()}_ERROR_KINDS` } : {}),
|
|
196
231
|
});
|
|
197
232
|
|
|
198
233
|
/** The api app root after the GraphQL server (platform/graphql) and the table's GraphQL transport are registered. */
|
|
@@ -458,7 +493,11 @@ export function addTable({
|
|
|
458
493
|
const generated = emitTypes({ root });
|
|
459
494
|
if (typeof generated === "string") edit(dbTypesPath, () => generated);
|
|
460
495
|
}
|
|
461
|
-
|
|
496
|
+
const kindsHome = kindsHomeOf(root, ownerRelative);
|
|
497
|
+
const kindsInRoot = kindsHome === ownerRelative;
|
|
498
|
+
edit(ownerRelative, (text) => wireDomainModule(text, ownerRelative, feature, { kindsInRoot }));
|
|
499
|
+
if (!kindsInRoot)
|
|
500
|
+
edit(kindsHome, (text) => wireKinds(text, kindsHome, { kinds: `${snakeOf(feature).toUpperCase()}_ERROR_KINDS`, specifier: `@modules/domain/${feature}` }));
|
|
462
501
|
const registrations = [
|
|
463
502
|
...(repo.edition === "lite" ? ["table"] : []),
|
|
464
503
|
...(fe ? ["api", "graphql"] : []),
|
package/scaffold/add.mjs
CHANGED
|
@@ -16,101 +16,18 @@
|
|
|
16
16
|
// `@@vCamel@@` its camelCase, `@@vUpper@@` its UPPER_SNAKE and `@@vSnake@@` its snake_case; the option `--service Class=module` gives `@@service@@` (the class) and `@@serviceModule@@`.
|
|
17
17
|
import fs from 'node:fs';
|
|
18
18
|
import path from 'node:path';
|
|
19
|
-
import { createSlotResolver, loadSlotManifest, readRepoDeclaration, ruleParams } from '../runtime/scripts/hfs/slots.mjs';
|
|
20
|
-
import { parseYaml } from '../runtime/engine/yaml.mjs';
|
|
21
19
|
import { TEMPLATES_DIR } from '../sync/index.mjs';
|
|
22
20
|
import { ScaffoldError, pascalOf } from './service.mjs';
|
|
23
|
-
import { refuseFullAdapterInLite, refuseInEdition } from './edition-gate.mjs';
|
|
24
21
|
import { addTable, graphqlPlatformFiles } from './add-table.mjs';
|
|
25
22
|
import { addApp } from './add-app.mjs';
|
|
26
23
|
import { ensureLiteCli } from './add-cli-lite.mjs';
|
|
27
|
-
import { addLiteWebhookInbox
|
|
24
|
+
import { addLiteWebhookInbox } from './add-inbox-lite.mjs';
|
|
25
|
+
import { addBuiltinLiteCli, fillKnownForms, formsOf, planKind, readAddContext } from './add-plan.mjs';
|
|
26
|
+
export { liteApiTemplates } from './add-plan.mjs';
|
|
28
27
|
import { bundledPins, jsonText, packageJsonText, peerDependencies } from './app.mjs';
|
|
29
28
|
import { byCodeUnit } from '../report/order.mjs';
|
|
30
29
|
import { registerLiteDomainService, registerLiteExports } from './lite-exports.mjs';
|
|
31
30
|
|
|
32
|
-
const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
33
|
-
const VARIABLE = /<([A-Za-z][A-Za-z0-9]*)>/g;
|
|
34
|
-
const PLACEHOLDER = /@@([A-Za-z][A-Za-z0-9]*)@@/g;
|
|
35
|
-
const PLATFORM_PREFIX = 'src/modules/platform/';
|
|
36
|
-
const TOPICS_DIR = path.join(import.meta.dirname, '..', 'runtime', 'knowledge', 'patterns', 'be');
|
|
37
|
-
const PATTERN_TEMPLATES = path.join(TEMPLATES_DIR, 'be', 'patterns');
|
|
38
|
-
|
|
39
|
-
const camelOf = (name) => { const pascal = pascalOf(name); return pascal[0].toLowerCase() + pascal.slice(1); };
|
|
40
|
-
|
|
41
|
-
/** The `files:` tree of the topic `topic`, read from the knowledge file the package carries. */
|
|
42
|
-
function readTree(topic) {
|
|
43
|
-
const file = path.join(TOPICS_DIR, `${topic}.yaml`);
|
|
44
|
-
if (!fs.existsSync(file)) throw new ScaffoldError('HFS_ADD_TOPIC_MISSING', `the pattern topic ${topic} (knowledge/patterns/be/${topic}.yaml) is not carried by this package`);
|
|
45
|
-
const doc = parseYaml(fs.readFileSync(file, 'utf8'));
|
|
46
|
-
if (!Array.isArray(doc?.files)) throw new ScaffoldError('HFS_ADD_TREE_MISSING', `knowledge/patterns/be/${topic}.yaml has no files tree`);
|
|
47
|
-
return doc.files;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** The parsed document of the topic `topic` (knowledge/patterns/be/<topic>.yaml) the package carries. */
|
|
51
|
-
const readTopic = (topic) => ({ id: topic, doc: parseYaml(fs.readFileSync(path.join(TOPICS_DIR, `${topic}.yaml`), 'utf8')) });
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* The lite api tree: the full tree, GraphQL door included (owner ruling 2026-10-13: lite and full have one code structure), with
|
|
55
|
-
* the command and handler that hand the verified Supabase principal to the domain service (lite's identity adapter).
|
|
56
|
-
*/
|
|
57
|
-
const selectLiteApiEntries = entries => entries.map(entry => {
|
|
58
|
-
if (entry.path === 'src/features/api/<feature>/application/<action>.command.ts') return { ...entry, template: 'api/application.command.principal.ts.tpl' };
|
|
59
|
-
if (entry.path === 'src/features/api/<feature>/application/<action>.handler.ts') return { ...entry, template: 'api/application.handler.principal.ts.tpl' };
|
|
60
|
-
return entry;
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
/** The templates the lite api tree names beyond the pattern topic's own files tree (the principal-aware command and handler): the template set stays one set with the topics' (tests/packages-hfs/hfs-add.spec.mjs). */
|
|
64
|
-
export const liteApiTemplates = () => selectLiteApiEntries(readTree('api')).map(entry => entry.template).filter(Boolean);
|
|
65
|
-
|
|
66
|
-
/** Fills the `<name>` variables of a path; returns null when a variable has no value. */
|
|
67
|
-
const fillPath = (text, values) => {
|
|
68
|
-
let missing = false;
|
|
69
|
-
const filled = text.replace(VARIABLE, (whole, name) => (values[name] === undefined ? ((missing = true), whole) : values[name]));
|
|
70
|
-
return missing ? null : filled;
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
/** The body of a template with its placeholders filled; an unknown placeholder is a refusal. */
|
|
74
|
-
const renderBody = (text, values, forms, template) => text.replace(PLACEHOLDER, (whole, name) => {
|
|
75
|
-
if (forms[name] === undefined) throw new ScaffoldError('HFS_ADD_PLACEHOLDER_UNKNOWN', `template ${template} uses ${whole}, which no variable of this noun provides`);
|
|
76
|
-
return forms[name];
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
/** The spellings of every variable: kebab (`job`), Pascal (`Job`), camel (`jobCamel`), UPPER_SNAKE (`jobUpper`) and snake_case (`jobSnake`). */
|
|
80
|
-
function formsOf(values) {
|
|
81
|
-
const forms = {};
|
|
82
|
-
for (const [name, value] of Object.entries(values)) {
|
|
83
|
-
const kebab = KEBAB.test(value);
|
|
84
|
-
forms[name] = value;
|
|
85
|
-
forms[name[0].toUpperCase() + name.slice(1)] = kebab ? pascalOf(value) : value[0].toUpperCase() + value.slice(1);
|
|
86
|
-
forms[`${name}Camel`] = kebab ? camelOf(value) : value[0].toLowerCase() + value.slice(1);
|
|
87
|
-
forms[`${name}Upper`] = kebab ? value.replaceAll('-', '_').toUpperCase() : value;
|
|
88
|
-
forms[`${name}Snake`] = kebab ? value.replaceAll('-', '_') : value;
|
|
89
|
-
}
|
|
90
|
-
return forms;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** The variables of the noun: the instance name, the options it needs and the defaults of the others. */
|
|
94
|
-
function variablesOf({ spec, noun, name, options }) {
|
|
95
|
-
if (!KEBAB.test(name)) throw new ScaffoldError('HFS_ADD_NAME_INVALID', `${noun} name ${name} must be kebab-case (letters, digits and single dashes)`);
|
|
96
|
-
const values = { [spec.variable]: name };
|
|
97
|
-
for (const need of spec.needs ?? []) {
|
|
98
|
-
const value = options[need];
|
|
99
|
-
if (typeof value !== 'string' || !value) throw new ScaffoldError('HFS_ADD_OPTION_MISSING', `starci app add ${noun} needs --${need}`);
|
|
100
|
-
if (need === 'service') {
|
|
101
|
-
const [className, module] = value.split('=');
|
|
102
|
-
if (!className || !module) throw new ScaffoldError('HFS_ADD_OPTION_INVALID', '--service takes <ServiceClass>=<module specifier>');
|
|
103
|
-
values.service = className;
|
|
104
|
-
values.serviceModule = module;
|
|
105
|
-
} else {
|
|
106
|
-
if (!KEBAB.test(value)) throw new ScaffoldError('HFS_ADD_OPTION_INVALID', `--${need} ${value} must be kebab-case`);
|
|
107
|
-
values[need] = value;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
for (const [key, rule] of Object.entries(spec.defaults ?? {})) if (values[key] === undefined) values[key] = rule === '@name' ? name : String(rule);
|
|
111
|
-
return values;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
31
|
/**
|
|
115
32
|
* Generates the tree of one noun in the app at `repoRoot` (the folder of hfs.json).
|
|
116
33
|
*
|
|
@@ -126,77 +43,28 @@ export function addKind({ repoRoot, noun, name, options = {}, now = Date.now })
|
|
|
126
43
|
const result = addApp({ root: repoRoot, name });
|
|
127
44
|
return { ...result, registered: { patterns: [], kinds: [] } };
|
|
128
45
|
}
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (!fs.existsSync(declarationFile)) throw new ScaffoldError('HFS_ADD_NOT_AN_APP', 'starci app add runs at the app root (the folder of hfs.json)');
|
|
135
|
-
const repo = readRepoDeclaration(manifest, repoRoot);
|
|
136
|
-
if (!repo.sides?.be) throw new ScaffoldError('HFS_ADD_NO_BACK_END', 'this app has no back-end side');
|
|
137
|
-
const liteCliBuiltIn = noun === 'cli' && repo.edition === 'lite' && (name === 'migrate' || name === 'seed');
|
|
138
|
-
if (liteCliBuiltIn) {
|
|
139
|
-
if (repo.sides.be.apps.some(app => app.kind === 'cli'))
|
|
140
|
-
throw new ScaffoldError('HFS_ADD_EXISTS', `the lite cli already carries its built-in ${name} group`);
|
|
141
|
-
return { created: ensureLiteCli({ root: repoRoot, manifest, repo }), registered: { patterns: [], kinds: ['cli'] } };
|
|
142
|
-
}
|
|
143
|
-
const nouns = [noun, ...(spec.also ?? [])];
|
|
144
|
-
const specs = nouns.map((each) => [each, params.addKinds[each]]);
|
|
145
|
-
const patterns = [...new Set(specs.flatMap(([, eachSpec]) => eachSpec.patterns))];
|
|
146
|
-
const topics = new Set([...specs.map(([, eachSpec]) => eachSpec.topic), ...topicsOfPatterns(patterns)]);
|
|
147
|
-
const liteCli = noun === 'cli' && repo.edition === 'lite';
|
|
148
|
-
const liteApi = noun === 'api' && repo.edition === 'lite';
|
|
149
|
-
// Lite has every pattern (owner ruling 2026-10-12), but a topic with a liteAdapter is generated only from full templates.
|
|
150
|
-
refuseFullAdapterInLite({ edition: repo.edition, topics: [...topics].map(readTopic), command: `add ${noun}` });
|
|
151
|
-
const trees = new Map([...topics].map((topic) => {
|
|
152
|
-
// Lite keeps every colocated unit spec of a tree (owner ruling 2026-10-12); only the webhook door and its spec are lite's own.
|
|
153
|
-
const entries = repo.edition === 'lite' ? selectLiteWebhookEntries(readTree(topic)) : readTree(topic);
|
|
154
|
-
return [topic, liteApi && topic === spec.topic ? selectLiteApiEntries(entries) : entries];
|
|
155
|
-
}));
|
|
156
|
-
// The slots the noun's trees name decide whether the edition has the noun (a slot lite does not carry or forbids
|
|
157
|
-
// makes it a full-edition capability); never the noun's name. Optional entries are never generated, so they gate nothing.
|
|
158
|
-
const writtenSlots = new Set();
|
|
159
|
-
for (const [each, eachSpec] of specs) for (const entry of trees.get(eachSpec.topic)) if (!entry.path.startsWith(PLATFORM_PREFIX) && entry.optional !== true && entry.slot) writtenSlots.add(entry.slot);
|
|
160
|
-
for (const topic of topics) for (const entry of trees.get(topic)) if (entry.path.startsWith(PLATFORM_PREFIX) && entry.optional !== true && entry.slot) writtenSlots.add(entry.slot);
|
|
161
|
-
refuseInEdition({ resolver: createSlotResolver(manifest, repo), slotIds: [...writtenSlots], command: `add ${noun}` });
|
|
162
|
-
const values = Object.assign({}, ...specs.map(([each, eachSpec]) => variablesOf({ spec: eachSpec, noun: each, name, options })), variablesOf({ spec, noun, name, options }));
|
|
163
|
-
const stamp = String(now()).padStart(13, '0').slice(0, 13);
|
|
164
|
-
const withStamp = { ...values, epochMs13: stamp };
|
|
165
|
-
const forms = formsOf(withStamp);
|
|
166
|
-
const beRoot = path.join(repoRoot, 'be');
|
|
167
|
-
|
|
168
|
-
const planned = [];
|
|
169
|
-
const consider = (entry, topic, instance, shared = false) => {
|
|
170
|
-
if (entry.optional === true) return;
|
|
171
|
-
const target = fillPath(entry.path, withStamp);
|
|
172
|
-
if (target === null) return;
|
|
173
|
-
const absolute = path.join(beRoot, ...target.split('/'));
|
|
174
|
-
// A platform capability is whole or absent: it exists when its index.ts does, and then none of its files is written again.
|
|
175
|
-
if (!instance && fs.existsSync(path.join(beRoot, ...target.split('/').slice(0, 4), 'index.ts'))) return;
|
|
176
|
-
if ((!instance || shared) && fs.existsSync(absolute)) return;
|
|
177
|
-
if (!entry.template) throw new ScaffoldError('HFS_ADD_TEMPLATE_MISSING', `${topic}.yaml lists ${entry.path} without a template, so it cannot be generated`);
|
|
178
|
-
const templateFile = path.join(PATTERN_TEMPLATES, ...entry.template.split('/'));
|
|
179
|
-
if (!fs.existsSync(templateFile)) throw new ScaffoldError('HFS_ADD_TEMPLATE_MISSING', `${topic}.yaml names template ${entry.template} for ${entry.path}, which the package does not carry`);
|
|
180
|
-
planned.push({ target, absolute, body: renderBody(fs.readFileSync(templateFile, 'utf8'), withStamp, forms, entry.template), instance });
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
for (const [each, eachSpec] of specs) for (const entry of trees.get(eachSpec.topic)) if (!entry.path.startsWith(PLATFORM_PREFIX)) consider(entry, eachSpec.topic, true, each !== noun);
|
|
184
|
-
for (const topic of topics) for (const entry of trees.get(topic)) if (entry.path.startsWith(PLATFORM_PREFIX)) consider(entry, topic, false);
|
|
185
|
-
|
|
186
|
-
const clash = planned.filter((file) => file.instance && fs.existsSync(file.absolute)).map((file) => file.target);
|
|
187
|
-
if (clash.length) throw new ScaffoldError('HFS_ADD_EXISTS', `${noun} ${name} already exists: ${clash.join(', ')}`);
|
|
46
|
+
const context = readAddContext({ repoRoot, noun, name, options, now });
|
|
47
|
+
const builtin = addBuiltinLiteCli(context);
|
|
48
|
+
if (builtin) return builtin;
|
|
49
|
+
return writeKind({ ...context, ...planKind(context) });
|
|
50
|
+
}
|
|
188
51
|
|
|
189
|
-
|
|
190
|
-
for (const file of planned) {
|
|
191
|
-
fs.mkdirSync(path.dirname(file.absolute), { recursive: true });
|
|
192
|
-
fs.writeFileSync(file.absolute, file.body);
|
|
193
|
-
}
|
|
52
|
+
function registerFeatureExports({ repoRoot, repo, noun, values }) {
|
|
194
53
|
if (noun === 'webhook' || (repo.edition === 'lite' && noun === 'api')) {
|
|
195
54
|
registerLiteExports({ root: repoRoot, generator: repo.edition === 'lite' ? noun : 'webhook-full' });
|
|
196
55
|
}
|
|
197
|
-
if (repo.edition === 'lite' &&
|
|
56
|
+
if (repo.edition === 'lite' && noun === 'api') {
|
|
198
57
|
registerLiteDomainService({ root: repoRoot, moduleSpecifier: values.serviceModule, service: values.service });
|
|
199
58
|
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writeKind({ repoRoot, noun, name, now, manifest, repo, spec, declarationFile, values, forms, beRoot, patterns, planned }) {
|
|
62
|
+
const bootstrapCreated = noun === 'cli' && repo.edition === 'lite' ? ensureLiteCli({ root: repoRoot, manifest, repo }) : [];
|
|
63
|
+
for (const file of planned) {
|
|
64
|
+
fs.mkdirSync(path.dirname(file.absolute), { recursive: true });
|
|
65
|
+
fs.writeFileSync(file.absolute, file.body);
|
|
66
|
+
}
|
|
67
|
+
registerFeatureExports({ repoRoot, repo, noun, values });
|
|
200
68
|
if (spec.wire) wire({ beRoot, wire: spec.wire, forms, noun, name });
|
|
201
69
|
const platformCreated = repo.edition === 'lite' && (noun === 'api' || noun === 'webhook') ? wireLiteFeature({ repoRoot, repo, noun, name }) : [];
|
|
202
70
|
const inboxCreated = addLiteWebhookInbox({ root: repoRoot, repo, noun, provider: name, values, now });
|
|
@@ -275,9 +143,12 @@ function wireLiteFeature({ repoRoot, repo, noun, name }) {
|
|
|
275
143
|
return created;
|
|
276
144
|
}
|
|
277
145
|
|
|
278
|
-
/** Adds one generated webhook provider to the lite API's
|
|
146
|
+
/** Adds one generated webhook provider to the lite API's typed env reader (or an existing inline bootstrap) and enables exact raw-body verification. */
|
|
279
147
|
function wireLiteWebhookConfig({ repoRoot, app, provider }) {
|
|
280
|
-
const
|
|
148
|
+
const envRelative = `be/apps/${app}/src/${app}.env.ts`;
|
|
149
|
+
const relative = fs.existsSync(path.join(repoRoot, ...envRelative.split('/')))
|
|
150
|
+
? envRelative
|
|
151
|
+
: `be/apps/${app}/src/main.ts`;
|
|
281
152
|
const file = path.join(repoRoot, ...relative.split('/'));
|
|
282
153
|
let text = fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
|
|
283
154
|
const httpSecurityImport = /^import \{ ([^}]+) \} from "@modules\/platform\/http-security"$/m;
|
|
@@ -290,11 +161,12 @@ function wireLiteWebhookConfig({ repoRoot, app, provider }) {
|
|
|
290
161
|
text = text.replace(httpSecurityImport, `import { ${imported.join(', ')} } from "@modules/platform/http-security"`);
|
|
291
162
|
}
|
|
292
163
|
const providerKey = /^[a-z][a-z0-9]*$/.test(provider) ? provider : JSON.stringify(provider);
|
|
293
|
-
const
|
|
164
|
+
const indent = /^([ \t]*)httpSecurity:/m.exec(text)?.[1] ?? ' ';
|
|
165
|
+
const providerLine = `${indent} ${providerKey}: parseWebhookProviderConfig(env, ${JSON.stringify(formsOf({ provider }).providerUpper)}),`;
|
|
294
166
|
if (!text.includes(providerLine)) {
|
|
295
167
|
const empty = /httpSecurity:\s*parseHttpSecurityConfig\(env\)/;
|
|
296
168
|
if (empty.test(text)) {
|
|
297
|
-
text = text.replace(empty, ['httpSecurity: {',
|
|
169
|
+
text = text.replace(empty, ['httpSecurity: {', `${indent} ...parseHttpSecurityConfig(env),`, `${indent} webhooks: {`, providerLine, `${indent} },`, `${indent}}`].join('\n'));
|
|
298
170
|
} else {
|
|
299
171
|
const found = /^\s*webhooks: \{$/m.exec(text);
|
|
300
172
|
if (!found) throw new ScaffoldError('HFS_ADD_WIRE_INVALID', `add webhook could not find httpSecurity options in ${relative}`);
|
|
@@ -304,6 +176,11 @@ function wireLiteWebhookConfig({ repoRoot, app, provider }) {
|
|
|
304
176
|
}
|
|
305
177
|
text = text.replace('NestFactory.create(AppModule.register(options))', 'NestFactory.create(AppModule.register(options), { rawBody: true })');
|
|
306
178
|
fs.writeFileSync(file, text);
|
|
179
|
+
if (relative === envRelative) {
|
|
180
|
+
const main = path.join(repoRoot, 'be', 'apps', app, 'src', 'main.ts');
|
|
181
|
+
const entry = fs.readFileSync(main, 'utf8').replace('NestFactory.create(AppModule.register(options))', 'NestFactory.create(AppModule.register(options), { rawBody: true })');
|
|
182
|
+
fs.writeFileSync(main, entry);
|
|
183
|
+
}
|
|
307
184
|
}
|
|
308
185
|
|
|
309
186
|
/**
|
|
@@ -313,7 +190,7 @@ function wireLiteWebhookConfig({ repoRoot, app, provider }) {
|
|
|
313
190
|
function wire({ beRoot, wire: target, forms, noun, name }) {
|
|
314
191
|
const file = path.join(beRoot, ...target.file.split('/'));
|
|
315
192
|
if (!fs.existsSync(file)) throw new ScaffoldError('HFS_ADD_WIRE_MISSING', `starci app add ${noun} ${name} registers the member in ${target.file}, which does not exist`);
|
|
316
|
-
const fill = (text) => text
|
|
193
|
+
const fill = (text) => fillKnownForms(text, forms);
|
|
317
194
|
const symbol = fill(target.symbol);
|
|
318
195
|
let text = fs.readFileSync(file, 'utf8');
|
|
319
196
|
if (new RegExp(`\\b${symbol}\\b`).test(text)) return;
|
|
@@ -326,16 +203,6 @@ function wire({ beRoot, wire: target, forms, noun, name }) {
|
|
|
326
203
|
fs.writeFileSync(file, text);
|
|
327
204
|
}
|
|
328
205
|
|
|
329
|
-
/** The topics that carry the platform capability of each pattern: the topic whose `pattern` key names it. */
|
|
330
|
-
function topicsOfPatterns(patterns) {
|
|
331
|
-
const topics = new Set();
|
|
332
|
-
for (const file of fs.readdirSync(TOPICS_DIR).filter((entry) => entry.endsWith('.yaml'))) {
|
|
333
|
-
const doc = parseYaml(fs.readFileSync(path.join(TOPICS_DIR, file), 'utf8'));
|
|
334
|
-
if (patterns.includes(doc?.pattern)) topics.add(file.slice(0, -'.yaml'.length));
|
|
335
|
-
}
|
|
336
|
-
return topics;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
206
|
/** Adds the patterns, the trigger kind and the opt-in slots of the noun to sides.be of hfs.json, keeping its other content and key order. */
|
|
340
207
|
function register({ declarationFile, spec }) {
|
|
341
208
|
const declaration = JSON.parse(fs.readFileSync(declarationFile, 'utf8'));
|
package/scaffold/app.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import { revParseQuery } from '../runtime/scripts/api/git/rev-parse-query.mjs';
|
|
|
27
27
|
import { lsFiles } from '../runtime/scripts/api/git/ls-files.mjs';
|
|
28
28
|
import { insidePath, samePath } from '../runtime/scripts/lib/path-key.mjs';
|
|
29
29
|
import { byCodeUnit } from '../report/order.mjs';
|
|
30
|
+
import { sonarGateName } from '../sync/sonar-key.mjs';
|
|
30
31
|
|
|
31
32
|
const NAME = /^[a-z][a-z0-9-]*$/;
|
|
32
33
|
const APP_DIR = '__app__';
|
|
@@ -353,8 +354,7 @@ export const PARTIAL_VARIABLES = new Proxy(Object.create(null), {
|
|
|
353
354
|
const CLI_APP_DIR = 'apps/cli/';
|
|
354
355
|
const declaresCliApp = app => app.sides.be.apps.some(entry => entry.kind === 'cli' && entry.name === 'cli');
|
|
355
356
|
|
|
356
|
-
const LITE_APP_REUSED = new Set(['.editorconfig', '.gitattributes', '.nvmrc'
|
|
357
|
-
'.starciwork/index.yaml', '.starciwork/features/system-health/index.yaml']);
|
|
357
|
+
const LITE_APP_REUSED = new Set(['.editorconfig', '.gitattributes', '.nvmrc']);
|
|
358
358
|
const LITE_DEFERRED_GENERATOR_FILES = new Set([
|
|
359
359
|
'src/modules/platform/database/database.sql.ts',
|
|
360
360
|
'src/modules/platform/database/migration-runner.service.ts',
|
|
@@ -413,10 +413,10 @@ function skeletonDirectory(scope, directory, app, vars, include = () => true) {
|
|
|
413
413
|
return files;
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
-
/** The canonical
|
|
416
|
+
/** The canonical full Work workspace, catalog and feature nodes an edition upgrade may need. */
|
|
417
417
|
export function scaffoldWorkSeeds(project) {
|
|
418
418
|
return skeletonDirectory('app', 'skeleton', { project }, {},
|
|
419
|
-
rel => rel === '.starciwork/index.yaml' || rel.startsWith('.starciwork/features/'));
|
|
419
|
+
rel => rel === '.starciwork/workspace.yaml' || rel === '.starciwork/index.yaml' || rel.startsWith('.starciwork/features/'));
|
|
420
420
|
}
|
|
421
421
|
|
|
422
422
|
/**
|
|
@@ -576,7 +576,7 @@ export function scaffoldApp({ name, into, presets, edition = 'full', manifest =
|
|
|
576
576
|
{ path: `fe/packages/${name}-${pkg}/tsconfig.json`, content: jsonText(fePackageTsconfig()) },
|
|
577
577
|
{ path: `fe/packages/${name}-${pkg}/tsconfig.build.json`, content: jsonText(fePackageBuildTsconfig()) },
|
|
578
578
|
])),
|
|
579
|
-
...['app', 'be', 'fe'].flatMap(scope => skeletonOf(scope, app, { sonarGate: parseYaml(fs.readFileSync(SONAR_GATE_FILE, 'utf8')).
|
|
579
|
+
...['app', 'be', 'fe'].flatMap(scope => skeletonOf(scope, app, { sonarGate: sonarGateName(parseYaml(fs.readFileSync(SONAR_GATE_FILE, 'utf8')), app.edition), timestamp, ...supabasePortVars(name) })),
|
|
580
580
|
];
|
|
581
581
|
const writes = scaffoldWrites(root, original);
|
|
582
582
|
try {
|
package/scaffold/image.mjs
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
// (templates/<side>/image/<kind>, the same render starci app scaffold uses). It never overwrites a file.
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { imageFiles, loadHfs, validateHfs } from '../sync/index.mjs';
|
|
5
|
+
import { appSource, imageFiles, loadHfs, validateHfs } from '../sync/index.mjs';
|
|
6
6
|
|
|
7
7
|
/** Writes the missing Dockerfiles of the app at `repoRoot` (the folder of hfs.json); the app-relative paths written. */
|
|
8
8
|
export function newImages({ repoRoot }) {
|
|
9
|
-
const files = imageFiles(validateHfs(loadHfs(repoRoot)));
|
|
9
|
+
const files = imageFiles(validateHfs(loadHfs(repoRoot)), appSource(repoRoot));
|
|
10
10
|
const written = [];
|
|
11
11
|
for (const file of files) {
|
|
12
12
|
const target = path.join(repoRoot, ...file.path.split('/'));
|
package/src/main.mjs
CHANGED
|
@@ -36,9 +36,10 @@
|
|
|
36
36
|
// one placeholder it per public method. Never overwrites a file. Both editions: a lite back end keeps full's unit specs (owner ruling 2026-10-12).
|
|
37
37
|
// starci app new image [--cwd <dir>] the Dockerfile of every declared app that has none, from the image canon (scaffold/image.mjs); never overwrites
|
|
38
38
|
// starci app new spec <file>.service.ts [--cwd <dir>] the spec skeleton of an existing service, read from its constructor with the repository's TypeScript
|
|
39
|
-
// starci app secret
|
|
40
|
-
//
|
|
41
|
-
//
|
|
39
|
+
// starci app secret init | set <env> <slug> <KEY> [--generate <bytes>] | get <env> <slug> [KEY] --to-file [--reveal]
|
|
40
|
+
// starci app secret materialize <env> | list <env>
|
|
41
|
+
// dotenv custody in .starcistacks, public age rule initialization, private KEY files outside Git;
|
|
42
|
+
// values reach stdout only when get explicitly receives --reveal.
|
|
42
43
|
// 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>]
|
|
43
44
|
// exactly that kind's file tree, generated FROM the files: tree of its pattern topic (knowledge/patterns/be) with the
|
|
44
45
|
// one template body of each entry (templates/be/patterns), plus the platform capabilities it needs when they are missing;
|
|
@@ -84,7 +85,11 @@ starci app emit [--cwd <dir>]
|
|
|
84
85
|
starci app new service <dir> <name> [--inject <binding>...] [--cwd <dir>]
|
|
85
86
|
starci app new spec <file>.service.ts [--cwd <dir>]
|
|
86
87
|
starci app new image [--cwd <dir>]
|
|
87
|
-
starci app secret
|
|
88
|
+
starci app secret init [--cwd <dir>]
|
|
89
|
+
starci app secret set <env> <slug> <KEY> [--generate <bytes>] [--cwd <dir>]
|
|
90
|
+
starci app secret get <env> <slug> [KEY] --to-file [--reveal] [--cwd <dir>]
|
|
91
|
+
starci app secret materialize <env> [--cwd <dir>]
|
|
92
|
+
starci app secret list <env> [--cwd <dir>]
|
|
88
93
|
starci app hygiene [--cwd <dir>]
|
|
89
94
|
`;
|
|
90
95
|
const PER_CODE_LIMIT = 25;
|
|
@@ -124,7 +129,7 @@ function printCheck(result, out) {
|
|
|
124
129
|
for (const f of list.slice(0, PER_CODE_LIMIT)) out(` - ${f.message}\n`);
|
|
125
130
|
if (list.length > PER_CODE_LIMIT) out(` ... and ${list.length - PER_CODE_LIMIT} more (use --json for all)\n`);
|
|
126
131
|
}
|
|
127
|
-
out(`\n${counts.error} error finding${counts.error === 1 ? '' : 's'}, ${counts.info} report-only\n`);
|
|
132
|
+
out(`\n${counts.error} error finding${counts.error === 1 ? '' : 's'}, ${counts.warning} warning${counts.warning === 1 ? '' : 's'}, ${counts.info} report-only\n`);
|
|
128
133
|
}
|
|
129
134
|
|
|
130
135
|
function printExplain(e, out) {
|
|
@@ -245,6 +250,7 @@ async function installedStackMain(cwd) {
|
|
|
245
250
|
/** `cwd`, `presets`, `prettier`, `lock`, `run` and `stackMain` are test seams (`run` is the Supabase types command runner); stdout and stderr never have to be process globals. */
|
|
246
251
|
export async function main(argv, {
|
|
247
252
|
cwd: baseCwd = process.cwd(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), presets, prettier, lock, run, stackMain,
|
|
253
|
+
env = process.env, stdin, sops, tools, platform,
|
|
248
254
|
} = {}) {
|
|
249
255
|
try {
|
|
250
256
|
const global = globalOptions(argv, path.resolve(baseCwd));
|
|
@@ -254,7 +260,7 @@ export async function main(argv, {
|
|
|
254
260
|
if (!VERBS.includes(verb)) { stderr(USAGE); return 2; }
|
|
255
261
|
if (verb === 'sync' || verb === 'hygiene') return await syncMain([verb, ...rest], { cwd: global.cwd, stdout: out });
|
|
256
262
|
if (verb === 'lint') return await lintMain(rest, { cwd: global.cwd, stdout: out, presets, prettier });
|
|
257
|
-
if (verb === 'secret') return secretMain(rest, { stdout: out, stderr, cwd: global.cwd });
|
|
263
|
+
if (verb === 'secret') return secretMain(rest, { stdout: out, stderr, cwd: global.cwd, env, stdin, sops, tools, platform });
|
|
258
264
|
if (verb === 'upgrade') return await upgradeMain(['--repo', global.cwd, ...(global.edition ? ['--edition', global.edition] : []), ...rest], { stdout: out, presets });
|
|
259
265
|
if (verb === 'stack') {
|
|
260
266
|
const run = stackMain ?? await installedStackMain(global.cwd);
|
package/sync/index.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { APP_SCOPE, SIDES, litePresenceOf, loadSlotManifest, resolveRepoDeclarat
|
|
|
24
24
|
import { managedGroupOf } from '../runtime/scripts/hfs/edition-slots.mjs';
|
|
25
25
|
import { feAppPackageName } from '../runtime/scripts/hfs/rules/monorepo.mjs';
|
|
26
26
|
import { DEFAULT_PORT, NODE_IMAGE, dockerfilePath } from '../runtime/scripts/hfs/rules/docker.mjs';
|
|
27
|
+
import { isFile } from '../runtime/scripts/lib/fs-kind.mjs';
|
|
27
28
|
import { readDeclaredSonarKey } from './sonar-key.mjs';
|
|
28
29
|
import { byCodeUnit } from '../report/order.mjs';
|
|
29
30
|
|
|
@@ -79,8 +80,8 @@ export function targetsOf(scope, { edition = 'full', manifest = loadSlotManifest
|
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
const unlisted = scope === APP_SCOPE
|
|
82
|
-
? UNLISTED_TARGETS.map(target => edition === 'lite'
|
|
83
|
-
? { ...target, template: 'app/gitignore-lite' }
|
|
83
|
+
? UNLISTED_TARGETS.map(target => edition === 'lite'
|
|
84
|
+
? { ...target, template: target.path === '.gitignore' ? 'app/gitignore-lite' : 'app/starciwork.gitignore-lite' }
|
|
84
85
|
: { ...target })
|
|
85
86
|
: [];
|
|
86
87
|
return [...listed, ...unlisted];
|
|
@@ -139,7 +140,9 @@ const SOURCE_SKIPPED = new Set(['node_modules', 'dist', 'coverage']);
|
|
|
139
140
|
|
|
140
141
|
/**
|
|
141
142
|
* The be source of the app at `root` the coverage components read: { files, read }, `files` be-relative posix paths of every `*.ts` file under
|
|
142
|
-
* be/apps and be/src (build output and dependencies skipped) and `read` their text.
|
|
143
|
+
* be/apps and be/src (build output and dependencies skipped) and `read` their text. `workPresent` records whether the optional
|
|
144
|
+
* lite brand tree exists, so sync can preserve its absence. `hasImage` identifies authored optional Dockerfiles without reading
|
|
145
|
+
* their content. An app with no be folder has no source files.
|
|
143
146
|
*/
|
|
144
147
|
export function appSource(root) {
|
|
145
148
|
const files = [];
|
|
@@ -152,7 +155,8 @@ export function appSource(root) {
|
|
|
152
155
|
}
|
|
153
156
|
};
|
|
154
157
|
for (const dir of ['apps', 'src']) walk(dir);
|
|
155
|
-
return { files: files.sort(byCodeUnit), read: file => fs.readFileSync(path.join(root, 'be', file), 'utf8')
|
|
158
|
+
return { files: files.sort(byCodeUnit), read: file => fs.readFileSync(path.join(root, 'be', file), 'utf8'),
|
|
159
|
+
workPresent: fs.existsSync(path.join(root, '.starciwork')), hasImage: file => isFile(path.join(root, file), { followLinks: false }) };
|
|
156
160
|
}
|
|
157
161
|
|
|
158
162
|
/**
|
|
@@ -183,7 +187,7 @@ export function jestCoverageSource(coverage) {
|
|
|
183
187
|
* `docker:build:<app>` builds the image of one app (the exact command its Dockerfile header states, tag `<project>/<app>:dev`) and
|
|
184
188
|
* `docker:build` builds every image, one after the other, never pushing.
|
|
185
189
|
*/
|
|
186
|
-
export function appScripts(app) {
|
|
190
|
+
export function appScripts(app, source) {
|
|
187
191
|
const line = (name, command) => `${JSON.stringify(name)}: ${JSON.stringify(command)},`;
|
|
188
192
|
const be = app.sides.be.apps;
|
|
189
193
|
const fe = app.sides.fe.apps;
|
|
@@ -194,7 +198,7 @@ export function appScripts(app) {
|
|
|
194
198
|
const workspace = entry => feAppPackageName(app.project, entry.name);
|
|
195
199
|
// Both editions generate before they run (owner ruling 2026-10-13): the fe's GraphQL wire types, and under lite the Supabase types check.
|
|
196
200
|
const dev = entry => `npm run codegen --silent && turbo run dev --filter=${workspace(entry)}`;
|
|
197
|
-
const images = imageEntries(app);
|
|
201
|
+
const images = imageEntries(app, source);
|
|
198
202
|
const imageCommand = ({ side, entry }) => `docker build -f ${dockerfilePath(side, entry.name)} -t ${app.project}/${entry.name}:dev .`;
|
|
199
203
|
return [
|
|
200
204
|
...(apis.length === 1 ? [line('dev:be', watch(apis[0]))] : apis.map(entry => line(`dev:be:${entry.name}`, watch(entry)))),
|
|
@@ -219,9 +223,10 @@ const opensPackages = side => (side.optionalSlots ?? []).some(id => id === 'repo
|
|
|
219
223
|
/** The stylesheets of the fe side the CSS canon judges, relative to fe/: every app and every workspace package (slot fe.route, fe.package.*). */
|
|
220
224
|
export const STYLE_GLOB = '{apps,packages}/*/src/**/*.css';
|
|
221
225
|
|
|
222
|
-
/**
|
|
223
|
-
function imageEntries(app) {
|
|
224
|
-
|
|
226
|
+
/** Required app images plus lite front ends whose optional Dockerfile the source holds. */
|
|
227
|
+
function imageEntries(app, source) {
|
|
228
|
+
const frontend = app.sides.fe.apps.filter(entry => app.edition !== 'lite' || source?.hasImage?.(dockerfilePath('fe', entry.name)));
|
|
229
|
+
return [...app.sides.be.apps.map(entry => ({ side: 'be', entry })), ...frontend.map(entry => ({ side: 'fe', entry }))];
|
|
225
230
|
}
|
|
226
231
|
|
|
227
232
|
/**
|
|
@@ -229,9 +234,9 @@ function imageEntries(app) {
|
|
|
229
234
|
* runs on the release tag, so every image is built there; no path filter narrows it. A YAML block mapping per entry, indented for
|
|
230
235
|
* `strategy.matrix.include`.
|
|
231
236
|
*/
|
|
232
|
-
export function imageMatrix(app) {
|
|
237
|
+
export function imageMatrix(app, source) {
|
|
233
238
|
const entry = (side, item) => ` - name: ${item.name}\n file: ${dockerfilePath(side, item.name)}`;
|
|
234
|
-
return imageEntries(app).map(image => entry(image.side, image.entry)).join('\n');
|
|
239
|
+
return imageEntries(app, source).map(image => entry(image.side, image.entry)).join('\n');
|
|
235
240
|
}
|
|
236
241
|
|
|
237
242
|
const IMAGE_TEMPLATE = Object.freeze({ api: 'be/image/api', worker: 'be/image/worker', cli: 'be/image/cli', next: 'fe/image/next' });
|
|
@@ -241,14 +246,14 @@ const IMAGE_TEMPLATE = Object.freeze({ api: 'be/image/api', worker: 'be/image/wo
|
|
|
241
246
|
* and then the app's own, judged by the docker rules (R187-R191). A Next image copies the fe packages only when the fe side opts into
|
|
242
247
|
* them and the be contracts only when the fe side reads them, so each COPY names a folder the app has.
|
|
243
248
|
*/
|
|
244
|
-
export function imageFiles(app) {
|
|
249
|
+
export function imageFiles(app, source) {
|
|
245
250
|
const side = app.sides.fe;
|
|
246
251
|
const inputs = [...(opensPackages(side) ? ['fe/packages'] : []), ...((side.reads ?? []).includes('be/contracts/') ? ['be/contracts'] : [])];
|
|
247
252
|
const copyInputs = inputs.map(folder => `COPY ${folder} ${folder}\n`).join('');
|
|
248
253
|
// The folders outside be/ that the back end declares it reads (the generated Supabase types of a lite back end, which an upgraded app keeps reading) are in every one of its images.
|
|
249
254
|
const beFolders = (app.sides.be.reads ?? []).map(folder => folder.replace(/\/$/, ''));
|
|
250
255
|
const copyBeInputs = beFolders.map(folder => `COPY ${folder} ${folder}\n`).join('');
|
|
251
|
-
const entries = imageEntries(app).map(({ side: which, entry: item }) => ({ side: which, item }));
|
|
256
|
+
const entries = imageEntries(app, source).map(({ side: which, entry: item }) => ({ side: which, item }));
|
|
252
257
|
return entries.map(({ side: which, item }) => {
|
|
253
258
|
const template = IMAGE_TEMPLATE[item.kind];
|
|
254
259
|
const vars = { project: app.project, app: item.name, nodeImage: NODE_IMAGE, port: DEFAULT_PORT, copyInputs, copyBeInputs };
|
|
@@ -265,7 +270,7 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
|
|
|
265
270
|
return {
|
|
266
271
|
header: HEADER(scope),
|
|
267
272
|
appBinary: APP_BINARY,
|
|
268
|
-
appScripts: appScripts(app),
|
|
273
|
+
appScripts: appScripts(app, source),
|
|
269
274
|
// turbo builds every fe app workspace; ^build (turbo.json) builds the packages each app imports first, and caches both.
|
|
270
275
|
buildFe: 'npm run codegen --silent && turbo run build --filter=./fe/apps/*',
|
|
271
276
|
// The be side with its tsc; every fe workspace with its own typecheck script through turbo (the packages are built first: ^build).
|
|
@@ -283,7 +288,7 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
|
|
|
283
288
|
codecovComponents: scope === APP_SCOPE ? componentsYaml(coverageComponents(manifest, { ...source, apps: app.sides.be.apps, providers })) : '',
|
|
284
289
|
tsconfigPaths: ['be/tsconfig.json', ...fe.apps.map(entry => `fe/apps/${entry.name}/tsconfig.json`), ...(packages ? ['fe/packages/*/tsconfig.json'] : [])].join(','),
|
|
285
290
|
styleGlob: STYLE_GLOB,
|
|
286
|
-
imageMatrix: imageMatrix(app),
|
|
291
|
+
imageMatrix: imageMatrix(app, source),
|
|
287
292
|
imageProject: app.project,
|
|
288
293
|
};
|
|
289
294
|
}
|
|
@@ -310,7 +315,9 @@ export function renderTargets(hfs, presets, { sonarKey, readTemplate = readBundl
|
|
|
310
315
|
const hasTemplate = name => { try { return readTemplate(name) !== undefined; } catch { return false; } };
|
|
311
316
|
return SCOPES.flatMap(scope => {
|
|
312
317
|
const vars = variables(app, scope, presets, sonarKey, manifest, source);
|
|
313
|
-
|
|
318
|
+
const targets = targetsOf(scope, { edition: app.edition, manifest, hasTemplate })
|
|
319
|
+
.filter(target => !(app.edition === 'lite' && source?.workPresent === false && target.path === '.starciwork/.gitignore'));
|
|
320
|
+
return targets.map(target => {
|
|
314
321
|
const body = render(readTemplate(target.template), vars, readTemplate);
|
|
315
322
|
const where = onSide(scope, target.path);
|
|
316
323
|
if (target.mode === 'scripts') {
|
package/templates/app/gitignore
CHANGED
|
@@ -2,19 +2,14 @@
|
|
|
2
2
|
# Commit gate (L0 only): work hygiene and the secrets guard of the staged files, eslint of the staged files of each side,
|
|
3
3
|
# stylelint over the staged CSS of fe, a prettier check of the staged files. Types and the targeted specs belong to the op gate.
|
|
4
4
|
npx starci app hygiene
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if [ -n "$fe" ]; then
|
|
11
|
-
(cd fe && npx eslint --max-warnings=0 --no-warn-ignored $fe)
|
|
12
|
-
fi
|
|
5
|
+
# NUL-delimited paths keep filenames intact; bounded batches fit the Windows command line.
|
|
6
|
+
git diff --cached --name-only --diff-filter=ACMR -z --relative=be -- '*.ts' '*.mts' '*.cts' '*.js' '*.mjs' '*.cjs' |
|
|
7
|
+
xargs -0 -n 50 sh -c 'if [ "$#" -gt 0 ]; then cd be && npx eslint -- "$@"; fi' sh || exit $?
|
|
8
|
+
git diff --cached --name-only --diff-filter=ACMR -z --relative=fe -- '*.ts' '*.tsx' '*.mts' '*.cts' '*.js' '*.jsx' '*.mjs' '*.cjs' |
|
|
9
|
+
xargs -0 -n 50 sh -c 'if [ "$#" -gt 0 ]; then cd fe && npx eslint --max-warnings=0 --no-warn-ignored -- "$@"; fi' sh || exit $?
|
|
13
10
|
styles=$(git diff --cached --name-only --diff-filter=ACMR --relative=fe -- '*.css' || true)
|
|
14
11
|
if [ -n "$styles" ]; then
|
|
15
12
|
(cd fe && npx stylelint $styles)
|
|
16
13
|
fi
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
npx prettier --check --ignore-unknown $formatted
|
|
20
|
-
fi
|
|
14
|
+
git diff --cached --name-only --diff-filter=ACMR -z |
|
|
15
|
+
xargs -0 -n 50 sh -c 'if [ "$#" -gt 0 ]; then npx prettier --check --ignore-unknown -- "$@"; fi' sh || exit $?
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Run `starci app secret init` to set a project-owned age recipient before encrypting the first secret.
|
|
2
2
|
creation_rules: []
|