@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.
Files changed (108) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +7 -0
  3. package/package.json +1 -1
  4. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  5. package/runtime/knowledge/hfs/rules.yaml +41 -13
  6. package/runtime/knowledge/hfs/slots.yaml +99 -43
  7. package/runtime/knowledge/patterns/be/api.yaml +64 -1
  8. package/runtime/knowledge/patterns/be/cli.yaml +23 -5
  9. package/runtime/knowledge/patterns/be/webhooks.yaml +187 -32
  10. package/runtime/knowledge/patterns/fe/folder.yaml +3 -0
  11. package/runtime/modules/kernel/failure-codes.yaml +15 -4
  12. package/runtime/scripts/api/git/cat-file.mjs +5 -0
  13. package/runtime/scripts/api/sops/resolve-sops.mjs +7 -0
  14. package/runtime/scripts/api/sops/seal.mjs +1 -1
  15. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +110 -0
  16. package/runtime/scripts/hfs/architecture/backend.mjs +20 -3
  17. package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
  18. package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
  19. package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
  20. package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
  21. package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
  22. package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
  23. package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
  24. package/runtime/scripts/hfs/architecture/dead-exports.mjs +14 -9
  25. package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
  26. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
  27. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
  28. package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
  29. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +21 -12
  30. package/runtime/scripts/hfs/architecture/register-once.mjs +13 -5
  31. package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
  32. package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
  33. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +3 -6
  34. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
  35. package/runtime/scripts/hfs/architecture/tiers.mjs +8 -1
  36. package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
  37. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +1 -11
  38. package/runtime/scripts/hfs/check.mjs +18 -26
  39. package/runtime/scripts/hfs/manifest-shape.mjs +27 -2
  40. package/runtime/scripts/hfs/path-findings.mjs +32 -2
  41. package/runtime/scripts/hfs/rules/database-migrations.mjs +36 -35
  42. package/runtime/scripts/hfs/rules/database.mjs +2 -2
  43. package/runtime/scripts/hfs/rules/edition.mjs +5 -5
  44. package/runtime/scripts/hfs/rules/proof-commands.mjs +2 -0
  45. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  46. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +128 -0
  47. package/runtime/scripts/hfs/rules/stacks.mjs +13 -8
  48. package/runtime/scripts/hfs/secret-document.mjs +97 -0
  49. package/runtime/scripts/hfs/secret-errors.mjs +20 -0
  50. package/runtime/scripts/hfs/secret-files.mjs +124 -0
  51. package/runtime/scripts/hfs/secret-init.mjs +192 -0
  52. package/runtime/scripts/hfs/secret-output.mjs +100 -0
  53. package/runtime/scripts/hfs/secret-sops.mjs +98 -0
  54. package/runtime/scripts/hfs/secret-store.mjs +161 -0
  55. package/runtime/scripts/hfs/secret.mjs +135 -195
  56. package/runtime/scripts/hfs/slot-imports.mjs +3 -1
  57. package/runtime/scripts/hfs/slot-required.mjs +13 -0
  58. package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
  59. package/runtime/scripts/hfs/tree-findings.mjs +22 -0
  60. package/runtime/scripts/lib/roots.mjs +59 -0
  61. package/runtime/scripts/lib/swarm-stack.mjs +139 -0
  62. package/runtime/scripts/lib/ts-analysis.mjs +20 -0
  63. package/scaffold/add-cli-lite.mjs +10 -11
  64. package/scaffold/add-inbox-lite.mjs +15 -84
  65. package/scaffold/add-plan.mjs +183 -0
  66. package/scaffold/add-table.mjs +44 -5
  67. package/scaffold/add.mjs +33 -166
  68. package/scaffold/app.mjs +5 -5
  69. package/scaffold/image.mjs +2 -2
  70. package/src/main.mjs +12 -6
  71. package/sync/index.mjs +23 -16
  72. package/templates/app/gitignore +1 -0
  73. package/templates/app/gitignore-lite +1 -0
  74. package/templates/app/hooks/husky/pre-commit +7 -12
  75. package/templates/app/skeleton/.sops.yaml +1 -1
  76. package/templates/app/skeleton-lite/.sops.yaml +1 -1
  77. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +16 -0
  78. package/templates/app/skeleton-lite/.starciwork/workspace.yaml +9 -0
  79. package/templates/app/skeleton-lite/README.md +4 -2
  80. package/templates/app/starciwork.gitignore-lite +6 -0
  81. package/templates/be/inbox/database-client.spec.ts.tpl +15 -20
  82. package/templates/be/inbox/database-client.ts.tpl +7 -22
  83. package/templates/be/inbox/service.spec.ts.tpl +16 -20
  84. package/templates/be/inbox/service.ts.tpl +6 -10
  85. package/templates/be/inbox/sql.ts.tpl +2 -2
  86. package/templates/be/inbox/webhook-door.spec.ts.tpl +5 -5
  87. package/templates/be/inbox/webhook-door.ts.tpl +4 -3
  88. package/templates/be/patterns/webhooks/webhook.door.spec.ts.tpl +3 -3
  89. package/templates/be/patterns/webhooks/webhook.door.ts.tpl +3 -3
  90. package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +21 -0
  91. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +1 -1
  92. package/templates/be/skeleton/apps/__app__/src/app.module.ts +7 -39
  93. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +42 -0
  94. package/templates/be/skeleton/apps/__app__/src/main.ts +3 -9
  95. package/templates/be/skeleton/apps/cli/src/cli.env.ts +13 -0
  96. package/templates/be/skeleton/apps/cli/src/cli.options.ts +6 -8
  97. package/templates/be/skeleton/apps/cli/src/main.ts +3 -2
  98. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +6 -6
  99. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +25 -0
  100. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +1 -1
  101. package/templates/be/skeleton-lite/apps/api/src/app.module.ts +6 -42
  102. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +45 -0
  103. package/templates/be/skeleton-lite/apps/api/src/main.ts +4 -11
  104. package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +17 -0
  105. package/upgrade/file-state.mjs +68 -0
  106. package/upgrade/index.mjs +7 -56
  107. package/runtime/scripts/api/git/log.mjs +0 -5
  108. package/templates/app/skeleton-lite/.starciwork/shell/index.yaml +0 -55
@@ -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
- /** The api app root after the table's domain module is registered (its module and error kinds). */
190
- const wireDomainModule = (text, relative, name) =>
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: `import { ${snakeOf(name).toUpperCase()}_ERROR_KINDS, ${pascalOf(name)}Module } from "@modules/domain/${name}"`,
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
- edit(ownerRelative, (text) => wireDomainModule(text, ownerRelative, feature));
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, selectLiteWebhookEntries } from './add-inbox-lite.mjs';
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 manifest = loadSlotManifest();
130
- const params = ruleParams(manifest, 'be');
131
- const spec = params.addKinds[noun];
132
- if (!spec) throw new ScaffoldError('HFS_ADD_NOUN_UNKNOWN', `starci app add knows ${Object.keys(params.addKinds).join(', ')}; ${noun} is none of them`);
133
- const declarationFile = path.join(repoRoot, 'hfs.json');
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
- const bootstrapCreated = liteCli ? ensureLiteCli({ root: repoRoot, manifest, repo }) : [];
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' && (noun === 'api' || noun === 'webhook')) {
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 parsed options and enables exact raw-body verification. */
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 relative = `be/apps/${app}/src/main.ts`;
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 providerLine = ` ${providerKey}: parseWebhookProviderConfig(env, ${JSON.stringify(formsOf({ provider }).providerUpper)}),`;
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: {', ' ...parseHttpSecurityConfig(env),', ' webhooks: {', providerLine, ' },', ' }'].join('\n'));
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.replace(PLACEHOLDER, (whole, key) => forms[key] ?? whole);
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', '.starciwork/workspace.yaml',
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 product catalog and feature nodes shared by scaffold and upgrades of older lite apps. */
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')).gate.name, timestamp, ...supabasePortVars(name) })),
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 {
@@ -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 list | show <slug> | set <slug> | gen <slug> [--key NAME] [--env NAME] [--age RECIPIENT] [--bytes N] [--cwd DIR]
40
- // the sealed secrets of `.starcistacks/<env>/secrets/` (runtime scripts/hfs/secret.mjs over the sops api): `set` reads the value from
41
- // stdin, `gen` seals a random one, `show` decrypts one to stdout; a plain `sops` command in a runbook is retired.
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 list|show|set|gen ... [--cwd <dir>]
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' && target.path === '.gitignore'
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. An app with no be folder has no files.
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
- /** The images a declaration renders commands and CI entries for. Lite's default deployable is the back-end API image. */
223
- function imageEntries(app) {
224
- return [...app.sides.be.apps.map(entry => ({ side: 'be', entry })), ...(app.edition === 'lite' ? [] : app.sides.fe.apps.map(entry => ({ side: 'fe', entry })))];
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
- return targetsOf(scope, { edition: app.edition, manifest, hasTemplate }).map(target => {
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') {
@@ -18,6 +18,7 @@ next-env.d.ts
18
18
  !.starcistacks/*/infra/compose/**
19
19
  !.starcistacks/*/infra/k8s/**
20
20
  !.starcistacks/*/infra/terraform/**
21
+ !.starcistacks/*/infra/swarm/**
21
22
  .starcistacks/*/infra/**/.env
22
23
  .starcistacks/*/infra/**/.env.*
23
24
  .starcistacks/*/infra/**/*.env
@@ -21,6 +21,7 @@ next-env.d.ts
21
21
  !.starcistacks/*/infra/compose/**
22
22
  !.starcistacks/*/infra/k8s/**
23
23
  !.starcistacks/*/infra/terraform/**
24
+ !.starcistacks/*/infra/swarm/**
24
25
  .starcistacks/*/infra/**/.env
25
26
  .starcistacks/*/infra/**/.env.*
26
27
  .starcistacks/*/infra/**/*.env
@@ -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
- be=$(git diff --cached --name-only --diff-filter=ACMR --relative=be -- '*.ts' '*.mts' '*.cts' '*.js' '*.mjs' '*.cjs' || true)
6
- if [ -n "$be" ]; then
7
- (cd be && npx eslint $be)
8
- fi
9
- fe=$(git diff --cached --name-only --diff-filter=ACMR --relative=fe -- '*.ts' '*.tsx' '*.mts' '*.cts' '*.js' '*.jsx' '*.mjs' '*.cjs' || true)
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
- formatted=$(git diff --cached --name-only --diff-filter=ACMR || true)
18
- if [ -n "$formatted" ]; then
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
- # Set a project-owned age recipient before encrypting the first secret.
1
+ # Run `starci app secret init` to set a project-owned age recipient before encrypting the first secret.
2
2
  creation_rules: []