@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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// roots.mjs - the one place that names a root of the runtime and says which tree wins when two hold the same record.
|
|
2
|
+
//
|
|
3
|
+
// A component that needs "which directory" asks here, so no component resolves it by itself (the wrong-tree reads of 2026-10-08/09:
|
|
4
|
+
// a brand record read from the product's main checkout, a capture tool looked up only in product dirs, generated files written
|
|
5
|
+
// through a home record, a Kernel start gated by another tree's build). The roots, each with its one resolver:
|
|
6
|
+
// runtime tree runtimeTree the checkout this code runs from (derived from the module location, never from a record)
|
|
7
|
+
// runtime state runtimeState <runtime tree>/.runtime, or STARCI_LOCAL_ROOT
|
|
8
|
+
// temp root tempRoot STARCI_TEMP_ROOT, the owner config, else the OS directory
|
|
9
|
+
// invocation dir invocationDir where the command was started; only ever a default for a flag the caller may override
|
|
10
|
+
// work dir workDirName the name of the Work records directory inside a product tree
|
|
11
|
+
// product trees treesInOrder the workflow tree first, then the product's main checkout
|
|
12
|
+
// Work records workRecordPath the first tree that holds the record, else the main checkout's path
|
|
13
|
+
// tool lookups toolSearchDirs the tree the work happens in first, the runtime's own install last
|
|
14
|
+
// The workflow tree itself, a job's placement and the Critic directory are registry facts: scripts/machine/workflow-tree.mjs,
|
|
15
|
+
// placement-rebound.mjs and the worktree registry resolve them, and a caller hands the result in as `tree`.
|
|
16
|
+
// The `root-adhoc` self-check (scripts/checks/check-root-adhoc.mjs, rule R240) refuses a new path built from the process working
|
|
17
|
+
// directory or from the Work directory name outside this module.
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { skillRoot, runtimeStateDir, starciLocalRoot, starciSourceRoot } from '../../engine/runtime-root.mjs';
|
|
21
|
+
import { tempRoot } from '../../engine/temp-root.mjs';
|
|
22
|
+
|
|
23
|
+
export { tempRoot, starciLocalRoot, starciSourceRoot };
|
|
24
|
+
|
|
25
|
+
/** The runtime tree this code runs from. */
|
|
26
|
+
export const runtimeTree = skillRoot;
|
|
27
|
+
|
|
28
|
+
/** The state directory of a runtime tree (default: this one). */
|
|
29
|
+
export const runtimeState = (tree = runtimeTree) => runtimeStateDir(tree);
|
|
30
|
+
|
|
31
|
+
/** The name of the Work records directory inside a product tree. */
|
|
32
|
+
export const WORK_DIR_NAME = '.starciwork';
|
|
33
|
+
|
|
34
|
+
/** The directory a command was started in: `io.cwd` when the caller injects one, else the process working directory. A default for flags only. */
|
|
35
|
+
export const invocationDir = (io = undefined) => path.resolve(io?.cwd ?? process.cwd());
|
|
36
|
+
|
|
37
|
+
/** The distinct, resolved trees that may hold a product's records, in precedence order: the workflow tree, then the main checkout. */
|
|
38
|
+
export const treesInOrder = ({ tree = null, repo = null } = {}) => [...new Set([tree, repo].filter(Boolean).map((dir) => path.resolve(dir)))];
|
|
39
|
+
|
|
40
|
+
/** The Work directories of those trees, in the same order. */
|
|
41
|
+
export const workDirsInOrder = ({ tree = null, repo = null, workDirName = WORK_DIR_NAME } = {}) => treesInOrder({ tree, repo }).map((dir) => path.join(dir, workDirName));
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The first Work directory (workflow tree first, then the main checkout) that `holds` accepts, else the main checkout's own (the
|
|
45
|
+
* path a refusal names), else the tree's when there is no main checkout.
|
|
46
|
+
*/
|
|
47
|
+
export function workDirHolding(holds, { tree = null, repo = null, workDirName = WORK_DIR_NAME } = {}) {
|
|
48
|
+
const dirs = workDirsInOrder({ tree, repo, workDirName });
|
|
49
|
+
return dirs.find((dir) => holds(dir)) ?? (repo ? path.join(path.resolve(repo), workDirName) : (dirs[0] ?? null));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The file of a Work record (`rel` under the Work directory) from the first tree that has it, else the main checkout's path. */
|
|
53
|
+
export function workRecordPath(rel, { tree = null, repo = null, workDirName = WORK_DIR_NAME, exists = fs.existsSync } = {}) {
|
|
54
|
+
const parts = String(rel).split('/');
|
|
55
|
+
return path.join(workDirHolding((dir) => exists(path.join(dir, ...parts)), { tree, repo, workDirName }) ?? '', ...parts);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The directories a tool is looked up from: the caller's (the tree the work happens in, then the project) first, the runtime's own install last. */
|
|
59
|
+
export const toolSearchDirs = (dirs, runtime = runtimeTree) => [...dirs.filter(Boolean), runtime].filter(Boolean);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Shared static Swarm contract for app checks and rendered deployment evidence.
|
|
2
|
+
import { isPlainObject as plain } from '../../engine/plain-object.mjs';
|
|
3
|
+
|
|
4
|
+
const PINNED_IMAGE = /@sha256:[a-f0-9]{64}$/i;
|
|
5
|
+
const VERSIONED_SECRET = /-v[1-9][0-9]*$/;
|
|
6
|
+
const BACKING_IMAGE = /^(?:postgres(?:ql)?|postgresql-repmgr|redis(?:-stack(?:-server)?)?|(?:cp-)?kafka(?:-native)?|keycloak|minio|rabbitmq|mysql(?:-server)?|mongo(?:db)?|mongodb-community-server)$/i;
|
|
7
|
+
const mapping = value => plain(value) ? value : {};
|
|
8
|
+
const present = value => typeof value === 'string' && Boolean(value.trim());
|
|
9
|
+
const configured = value => Object.keys(mapping(value)).length > 0;
|
|
10
|
+
const schedulerImage = image => present(image) && /(?:^|\/)swarm-cronjob(?:[:@]|$)/.test(image);
|
|
11
|
+
|
|
12
|
+
/** Compose includes may be a path or an object whose path is a string or a list of source paths. */
|
|
13
|
+
export function composeIncludePaths(doc) {
|
|
14
|
+
const paths = [], errors = [];
|
|
15
|
+
if (doc.include === undefined) return { paths, errors };
|
|
16
|
+
if (!Array.isArray(doc.include)) return { paths, errors: ['Compose include must be an array of source paths'] };
|
|
17
|
+
for (const entry of doc.include) {
|
|
18
|
+
let values = [entry];
|
|
19
|
+
if (plain(entry)) values = Array.isArray(entry.path) ? entry.path : [entry.path];
|
|
20
|
+
if (!values.length || values.some(value => !present(value))) errors.push('Compose include must declare nonempty string source paths');
|
|
21
|
+
else paths.push(...values);
|
|
22
|
+
}
|
|
23
|
+
return { paths, errors };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function runnableHealthcheck(value) {
|
|
27
|
+
if (present(value)) return value !== 'NONE';
|
|
28
|
+
if (!Array.isArray(value) || !['CMD', 'CMD-SHELL'].includes(value[0])) return false;
|
|
29
|
+
return value.slice(1).some(present);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Match image repository names, including registry and vendor namespaces, without matching application tags. */
|
|
33
|
+
function duplicatesManagedBacking(image) {
|
|
34
|
+
if (!present(image)) return false;
|
|
35
|
+
const repository = image.split('@')[0].split('/').at(-1).split(':')[0];
|
|
36
|
+
return BACKING_IMAGE.test(repository);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function jobDeclarationIssues(id, component, components, add) {
|
|
40
|
+
if (component.mode !== 'replicated-job') return;
|
|
41
|
+
const scheduler = components[component.scheduler];
|
|
42
|
+
if (!present(component.scheduler) || !plain(scheduler) || scheduler.role !== 'scheduler' || scheduler.mode === 'replicated-job')
|
|
43
|
+
add(`components.${id}.scheduler`, 'replicated-job must name a declared service component with role scheduler');
|
|
44
|
+
else if (!schedulerImage(scheduler.image)) add(`components.${id}.scheduler`, 'replicated-job scheduler must declare the swarm-cronjob image');
|
|
45
|
+
if (!present(component.schedule)) add(`components.${id}.schedule`, 'replicated-job must declare its cron schedule');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Inventory rules are independent of a selected environment or rendered deployment model. */
|
|
49
|
+
export function swarmInventoryIssues({ components, lite = false }) {
|
|
50
|
+
const errors = [], catalog = mapping(components);
|
|
51
|
+
const add = (at, message) => errors.push({ at, message });
|
|
52
|
+
for (const [id, entry] of Object.entries(catalog)) {
|
|
53
|
+
const component = mapping(entry);
|
|
54
|
+
if (lite && duplicatesManagedBacking(component.image))
|
|
55
|
+
add(`components.${id}.image`, 'lite must use managed Supabase backing services; this image duplicates a Supabase-provided service');
|
|
56
|
+
jobDeclarationIssues(id, component, catalog, add);
|
|
57
|
+
}
|
|
58
|
+
return errors;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function serviceSafetyIssues(id, service, add) {
|
|
62
|
+
const at = `services.${id}`, deploy = mapping(service.deploy), healthcheck = mapping(service.healthcheck);
|
|
63
|
+
if (!present(service.image)) add(`${at}.image`, 'managed Swarm service needs a nonempty rendered image');
|
|
64
|
+
else if (!PINNED_IMAGE.test(service.image)) add(`${at}.image`, 'Swarm image must be pinned by a sha256 digest');
|
|
65
|
+
if (service.build !== undefined) add(`${at}.build`, 'rendered Swarm model must not retain a build section');
|
|
66
|
+
if (!runnableHealthcheck(healthcheck.test) || healthcheck.disable === true)
|
|
67
|
+
add(`${at}.healthcheck`, 'every Swarm service needs an enabled healthcheck with a test');
|
|
68
|
+
const update = mapping(deploy.update_config);
|
|
69
|
+
if (!['start-first', 'stop-first'].includes(update.order) || update.failure_action !== 'rollback' || !update.monitor)
|
|
70
|
+
add(`${at}.deploy.update_config`, 'Swarm update_config must declare order, failure_action: rollback and monitor');
|
|
71
|
+
if (!configured(deploy.rollback_config)) add(`${at}.deploy.rollback_config`, 'every Swarm service needs deploy.rollback_config');
|
|
72
|
+
if (!['none', 'any', 'on-failure'].includes(deploy.restart_policy?.condition))
|
|
73
|
+
add(`${at}.deploy.restart_policy`, 'every Swarm service needs deploy.restart_policy with a condition');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function labelsOf(value) {
|
|
77
|
+
if (!Array.isArray(value)) return mapping(value);
|
|
78
|
+
return Object.fromEntries(value.map(label => {
|
|
79
|
+
const text = String(label), separator = text.indexOf('=');
|
|
80
|
+
return separator < 0 ? [text, ''] : [text.slice(0, separator), text.slice(separator + 1)];
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function jobServiceIssues(id, service, component, services, bindings, add) {
|
|
85
|
+
const deploy = mapping(service.deploy), at = `services.${id}.deploy`;
|
|
86
|
+
if (component.mode !== 'replicated-job') {
|
|
87
|
+
if (deploy.mode === 'replicated-job') add(`${at}.mode`, 'replicated-job deployment must be declared as mode: replicated-job in the component inventory');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (deploy.mode !== 'replicated-job') add(`${at}.mode`, 'replicated-job component must use deploy.mode: replicated-job');
|
|
91
|
+
if (deploy.replicas !== 0) add(`${at}.replicas`, 'scheduled replicated-job must keep deploy.replicas: 0 until its scheduler triggers it');
|
|
92
|
+
if (deploy.restart_policy?.condition !== 'none') add(`${at}.restart_policy.condition`, 'scheduled replicated-job must use restart_policy.condition: none');
|
|
93
|
+
const labels = labelsOf(deploy.labels);
|
|
94
|
+
if (String(labels['swarm.cronjob.enable']) !== 'true') add(`${at}.labels`, 'scheduled replicated-job needs swarm.cronjob.enable: "true"');
|
|
95
|
+
if (!present(component.schedule) || labels['swarm.cronjob.schedule'] !== component.schedule)
|
|
96
|
+
add(`${at}.labels`, 'swarm.cronjob.schedule must match the component cron schedule');
|
|
97
|
+
if (!services[component.scheduler] || !Object.hasOwn(bindings, component.scheduler))
|
|
98
|
+
add(`components.${id}.scheduler`, 'the declared scheduler must be bound and deployed in this environment');
|
|
99
|
+
else if (!schedulerImage(services[component.scheduler].image))
|
|
100
|
+
add(`components.${id}.scheduler`, 'the deployed scheduler must run the declared swarm-cronjob image');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function secretIssues(model, services, add) {
|
|
104
|
+
const secrets = mapping(model.secrets);
|
|
105
|
+
for (const [id, entry] of Object.entries(secrets)) {
|
|
106
|
+
const secret = mapping(entry);
|
|
107
|
+
if (secret.external !== true || !present(secret.name) || !VERSIONED_SECRET.test(secret.name) || secret.file !== undefined || secret.environment !== undefined || secret.content !== undefined)
|
|
108
|
+
add(`secrets.${id}`, 'Swarm secrets must be external: true with a versioned name <logical>-v<N> and no embedded value source');
|
|
109
|
+
}
|
|
110
|
+
for (const [id, service] of Object.entries(services)) {
|
|
111
|
+
for (const entry of Array.isArray(service?.secrets) ? service.secrets : []) {
|
|
112
|
+
const source = plain(entry) ? entry.source : entry;
|
|
113
|
+
if (!Object.hasOwn(secrets, source)) add(`services.${id}.secrets`, 'Swarm service secret must reference a declared external versioned secret');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Validate the same deployment model in HFS and the deployment evidence gate. */
|
|
119
|
+
export function swarmModelIssues({ model, components, bindings, lite = false }) {
|
|
120
|
+
const errors = [], services = mapping(model?.services), catalog = mapping(components), bound = mapping(bindings);
|
|
121
|
+
const add = (at, message) => errors.push({ at, message });
|
|
122
|
+
for (const [id, entry] of Object.entries(services)) {
|
|
123
|
+
const service = mapping(entry), component = mapping(catalog[id]);
|
|
124
|
+
serviceSafetyIssues(id, service, add);
|
|
125
|
+
if (!Object.hasOwn(catalog, id)) add(`services.${id}`, 'Swarm service name must be a declared component id');
|
|
126
|
+
if (service.ports !== undefined && !Array.isArray(service.ports))
|
|
127
|
+
add(`services.${id}.ports`, 'Swarm ports must be an array of published port definitions');
|
|
128
|
+
else if (service.ports?.length && component.role !== 'ingress')
|
|
129
|
+
add(`services.${id}.ports`, 'only a component with role ingress may publish Swarm ports');
|
|
130
|
+
if (lite && duplicatesManagedBacking(service.image))
|
|
131
|
+
add(`services.${id}.image`, 'lite stack must not run an image that duplicates a Supabase-provided backing service');
|
|
132
|
+
jobServiceIssues(id, service, component, services, bound, add);
|
|
133
|
+
}
|
|
134
|
+
for (const id of Object.keys(bound)) {
|
|
135
|
+
if (!Object.hasOwn(services, id)) add(`services.${id}`, 'environment component binding has no deployed Swarm service');
|
|
136
|
+
}
|
|
137
|
+
secretIssues(mapping(model), services, add);
|
|
138
|
+
return errors;
|
|
139
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The expression under the wrappers selected by an AST check's contract. */
|
|
2
|
+
export function unwrapExpression(ts, expression, { awaitExpressions = false, typeAssertions = true, satisfiesExpressions = true } = {}) {
|
|
3
|
+
while (expression && (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression)
|
|
4
|
+
|| (typeAssertions && ts.isTypeAssertionExpression(expression)) || ts.isNonNullExpression(expression)
|
|
5
|
+
|| (satisfiesExpressions && (ts.isSatisfiesExpression?.(expression) ?? false))
|
|
6
|
+
|| (awaitExpressions && ts.isAwaitExpression(expression)))) expression = expression.expression;
|
|
7
|
+
return expression;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Local name -> imported name and module; callers select their existing module key and literal policy. */
|
|
11
|
+
export function importsOf(ts, sourceFile, { moduleKey = 'module', stringLiteralsOnly = false } = {}) {
|
|
12
|
+
const imports = new Map();
|
|
13
|
+
for (const statement of sourceFile.statements) {
|
|
14
|
+
if (!ts.isImportDeclaration(statement) || (stringLiteralsOnly && !ts.isStringLiteralLike(statement.moduleSpecifier))) continue;
|
|
15
|
+
const bindings = statement.importClause?.namedBindings;
|
|
16
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
17
|
+
for (const binding of bindings.elements) imports.set(binding.name.text, { name: (binding.propertyName ?? binding.name).text, [moduleKey]: statement.moduleSpecifier.text });
|
|
18
|
+
}
|
|
19
|
+
return imports;
|
|
20
|
+
}
|
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { resolveRepoDeclaration } from "../runtime/scripts/hfs/slots.mjs";
|
|
4
4
|
import { parseYaml } from "../runtime/engine/yaml.mjs";
|
|
5
|
-
import { appScripts, imageFiles, TEMPLATES_DIR } from "../sync/index.mjs";
|
|
5
|
+
import { appScripts, appSource, imageFiles, TEMPLATES_DIR } from "../sync/index.mjs";
|
|
6
6
|
import { ScaffoldError } from "./service.mjs";
|
|
7
7
|
import { jsonText, packageJsonText } from "./app.mjs";
|
|
8
8
|
import { registerLiteExports } from "./lite-exports.mjs";
|
|
@@ -15,14 +15,12 @@ const read = (relative) =>
|
|
|
15
15
|
const PINS_FILE = path.join(import.meta.dirname, "..", "runtime", "knowledge", "hfs", "canon-pins.yaml");
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
const managedScripts = (repo) => {
|
|
19
|
-
const fragment = appScripts(repo).replace(/,\s*$/, "");
|
|
18
|
+
const managedScripts = (repo, source) => {
|
|
19
|
+
const fragment = appScripts(repo, source).replace(/,\s*$/, "");
|
|
20
20
|
return fragment === "" ? {} : JSON.parse(`{${fragment}}`);
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
-
const CLI_OPTIONS = `import type {
|
|
24
|
-
import { parsePrimaryDatabaseConfig } from "@modules/platform/database"
|
|
25
|
-
import type { DatabaseConnectionOptions } from "@modules/platform/database"
|
|
23
|
+
const CLI_OPTIONS = `import type { DatabaseConnectionOptions } from "@modules/platform/database"
|
|
26
24
|
|
|
27
25
|
/** Everything the lite cli needs: the least-privilege Supabase PostgreSQL connection. */
|
|
28
26
|
export interface CliAppOptions {
|
|
@@ -30,9 +28,9 @@ export interface CliAppOptions {
|
|
|
30
28
|
readonly connections: ReadonlyArray<DatabaseConnectionOptions>
|
|
31
29
|
}
|
|
32
30
|
|
|
33
|
-
/**
|
|
34
|
-
export const
|
|
35
|
-
connections: [
|
|
31
|
+
/** Uses the parsed connection without adding entities or TypeORM migrations. */
|
|
32
|
+
export const createCliAppOptions = (primary: DatabaseConnectionOptions): CliAppOptions => ({
|
|
33
|
+
connections: [primary],
|
|
36
34
|
})
|
|
37
35
|
`;
|
|
38
36
|
|
|
@@ -142,7 +140,7 @@ function enableLiteCliDatabase(root) {
|
|
|
142
140
|
}
|
|
143
141
|
|
|
144
142
|
/**
|
|
145
|
-
* Creates the optional lite cli app and
|
|
143
|
+
* Creates the optional lite cli app, its typed CliEnv/readCliEnv boundary and Supabase migrate/seed groups on first `add cli`.
|
|
146
144
|
* Existing cli trees are never repaired or overwritten: drift remains a check finding.
|
|
147
145
|
*/
|
|
148
146
|
export function ensureLiteCli({ root, manifest, repo }) {
|
|
@@ -182,6 +180,7 @@ export function ensureLiteCli({ root, manifest, repo }) {
|
|
|
182
180
|
"be/apps/cli/src/app.module.ts",
|
|
183
181
|
read("be/skeleton/apps/cli/src/app.module.ts"),
|
|
184
182
|
],
|
|
183
|
+
["be/apps/cli/src/cli.env.ts", read("be/skeleton-lite/apps/cli/src/cli.env.ts")],
|
|
185
184
|
["be/apps/cli/src/cli.options.ts", CLI_OPTIONS],
|
|
186
185
|
[
|
|
187
186
|
"be/src/features/cli/index.ts",
|
|
@@ -273,7 +272,7 @@ export function ensureLiteCli({ root, manifest, repo }) {
|
|
|
273
272
|
packageManifest.dependencies = { ...(packageManifest.dependencies ?? {}), "nest-commander": pin };
|
|
274
273
|
packageManifest.scripts = {
|
|
275
274
|
...(packageManifest.scripts ?? {}),
|
|
276
|
-
...managedScripts(resolved),
|
|
275
|
+
...managedScripts(resolved, appSource(root)),
|
|
277
276
|
};
|
|
278
277
|
for (const [relative, body] of files) {
|
|
279
278
|
const target = path.join(root, ...relative.split("/"));
|
|
@@ -25,13 +25,13 @@ const template = (relative, values) => {
|
|
|
25
25
|
});
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
-
/** The lite templates of the webhook door and its unit spec: the door
|
|
28
|
+
/** The lite templates of the webhook door and its unit spec: the door records the delivery in the provider inbox. */
|
|
29
29
|
const LITE_WEBHOOK_TEMPLATES = Object.freeze({
|
|
30
30
|
"webhooks/webhook.door.ts.tpl": "../inbox/webhook-door.ts.tpl",
|
|
31
31
|
"webhooks/webhook.door.spec.ts.tpl": "../inbox/webhook-door.spec.ts.tpl",
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
/** Lite replaces only the webhook door and its spec
|
|
34
|
+
/** Lite replaces only the webhook door and its spec with the durable provider inbox intake. */
|
|
35
35
|
export const selectLiteWebhookEntries = (entries) =>
|
|
36
36
|
entries.map((entry) =>
|
|
37
37
|
LITE_WEBHOOK_TEMPLATES[entry.template] === undefined
|
|
@@ -39,86 +39,24 @@ export const selectLiteWebhookEntries = (entries) =>
|
|
|
39
39
|
: { ...entry, template: LITE_WEBHOOK_TEMPLATES[entry.template] },
|
|
40
40
|
);
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
for (let index = open; index < text.length; index += 1) {
|
|
45
|
-
if (text[index] === "{") depth += 1;
|
|
46
|
-
else if (text[index] === "}") {
|
|
47
|
-
depth -= 1;
|
|
48
|
-
if (depth === 0) return index;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
return -1;
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const addEntityManagerImport = (text) => {
|
|
55
|
-
if (/import type \{[^}]*\bEntityManager\b[^}]*\} from "typeorm"/.test(text))
|
|
56
|
-
return text;
|
|
57
|
-
return `import type { EntityManager } from "typeorm"\n${text}`;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
/** Gives the selected domain intake the transaction manager while preserving its existing processing body. */
|
|
61
|
-
function adaptedDomainService({ root, service, serviceModule, provider, Provider }) {
|
|
42
|
+
/** Validates the selected processing owner for a later reactor/job without invoking or changing that service in the door. */
|
|
43
|
+
function validateProcessingOwner({ root, service, serviceModule, provider }) {
|
|
62
44
|
const prefix = "@modules/domain/";
|
|
63
|
-
|
|
45
|
+
const capability = serviceModule.slice(prefix.length);
|
|
46
|
+
if (!serviceModule.startsWith(prefix) || !/^[a-z][a-z0-9-]*(?:\/[a-z][a-z0-9-]*)*$/.test(capability))
|
|
64
47
|
throw new ScaffoldError(
|
|
65
48
|
"HFS_ADD_INBOX_SERVICE",
|
|
66
|
-
`lite add webhook needs a
|
|
49
|
+
`lite add webhook needs a processing service module below ${prefix}`,
|
|
67
50
|
);
|
|
68
|
-
const owner = path.join(
|
|
69
|
-
|
|
70
|
-
"
|
|
71
|
-
"src",
|
|
72
|
-
"modules",
|
|
73
|
-
"domain",
|
|
74
|
-
...serviceModule.slice(prefix.length).split("/"),
|
|
75
|
-
);
|
|
76
|
-
const candidates = filesBelow(owner).filter(
|
|
77
|
-
(file) =>
|
|
78
|
-
file.endsWith(".service.ts") &&
|
|
79
|
-
new RegExp(`\\bexport\\s+class\\s+${service}\\b`).test(
|
|
80
|
-
fs.readFileSync(file, "utf8"),
|
|
81
|
-
),
|
|
51
|
+
const owner = path.join(root, "be", "src", "modules", "domain", ...capability.split("/"));
|
|
52
|
+
const candidates = filesBelow(owner).filter((file) =>
|
|
53
|
+
file.endsWith(".service.ts") && new RegExp(`\\bexport\\s+class\\s+${service}\\b`).test(fs.readFileSync(file, "utf8")),
|
|
82
54
|
);
|
|
83
55
|
if (candidates.length !== 1)
|
|
84
|
-
throw new ScaffoldError(
|
|
85
|
-
"HFS_ADD_INBOX_SERVICE",
|
|
86
|
-
`${serviceModule} must export exactly one ${service} class from a .service.ts file before adding the ${provider} webhook`,
|
|
87
|
-
);
|
|
88
|
-
const file = candidates[0];
|
|
89
|
-
let text = fs.readFileSync(file, "utf8").replace(/\r\n/g, "\n");
|
|
90
|
-
const method = `accept${Provider}Delivery`;
|
|
91
|
-
const methodAt = text.search(new RegExp(`\\b${method}\\s*\\(`));
|
|
92
|
-
if (methodAt < 0)
|
|
93
|
-
throw new ScaffoldError(
|
|
94
|
-
"HFS_ADD_INBOX_INTAKE",
|
|
95
|
-
`${service} must declare ${method}(delivery) before adding the ${provider} webhook`,
|
|
96
|
-
);
|
|
97
|
-
const paramsOpen = text.indexOf("(", methodAt);
|
|
98
|
-
const paramsClose = text.indexOf(")", paramsOpen);
|
|
99
|
-
const bodyOpen = text.indexOf("{", paramsClose);
|
|
100
|
-
const bodyClose = matchingBrace(text, bodyOpen);
|
|
101
|
-
if (paramsClose < 0 || bodyOpen < 0 || bodyClose < 0)
|
|
102
|
-
throw new ScaffoldError(
|
|
103
|
-
"HFS_ADD_INBOX_INTAKE",
|
|
104
|
-
`${service}.${method} is not a readable method declaration`,
|
|
105
|
-
);
|
|
106
|
-
const parameters = text.slice(paramsOpen + 1, paramsClose).trim();
|
|
107
|
-
if (/(?:^|,)\s*(?:manager|entityManager)\s*:/.test(parameters)) return { file, text };
|
|
108
|
-
const manager =
|
|
109
|
-
/private\s+readonly\s+([A-Za-z][A-Za-z0-9]*)\s*:\s*EntityManager/.exec(
|
|
110
|
-
text,
|
|
111
|
-
)?.[1];
|
|
112
|
-
if (!manager)
|
|
113
56
|
throw new ScaffoldError(
|
|
114
57
|
"HFS_ADD_INBOX_INTAKE",
|
|
115
|
-
`${
|
|
58
|
+
`${serviceModule} must export exactly one ${service} from a .service.ts file as the processing owner of the ${provider} inbox`,
|
|
116
59
|
);
|
|
117
|
-
const body = text
|
|
118
|
-
.slice(bodyOpen + 1, bodyClose)
|
|
119
|
-
.replaceAll(`this.${manager}`, "entityManager");
|
|
120
|
-
text = `${text.slice(0, paramsOpen + 1)}${parameters}, entityManager: EntityManager = this.${manager}${text.slice(paramsClose, bodyOpen + 1)}${body}${text.slice(bodyClose)}`;
|
|
121
|
-
return { file, text: addEntityManagerImport(text) };
|
|
122
60
|
}
|
|
123
61
|
|
|
124
62
|
const databasePatches = (root) => {
|
|
@@ -131,8 +69,8 @@ const databasePatches = (root) => {
|
|
|
131
69
|
"lite add webhook needs the scaffolded platform/database capability",
|
|
132
70
|
);
|
|
133
71
|
let index = fs.readFileSync(indexFile, "utf8").replace(/\r\n/g, "\n");
|
|
134
|
-
if (!index.includes('export {
|
|
135
|
-
index = `${index.replace(/\n+$/, "")}\nexport {
|
|
72
|
+
if (!index.includes('export { recordWebhookDelivery } from "./webhook-inbox.client"'))
|
|
73
|
+
index = `${index.replace(/\n+$/, "")}\nexport { recordWebhookDelivery } from "./webhook-inbox.client"\n`;
|
|
136
74
|
let error = fs.readFileSync(errorFile, "utf8").replace(/\r\n/g, "\n");
|
|
137
75
|
const enumLine =
|
|
138
76
|
' WebhookDeliveryIdRequired = "DATABASE_WEBHOOK_DELIVERY_ID_REQUIRED",';
|
|
@@ -199,7 +137,7 @@ const wiredAppModule = ({ root, repo, provider, Provider }) => {
|
|
|
199
137
|
return { file, text: `${lines.join("\n").replace(/\n+$/, "")}\n` };
|
|
200
138
|
};
|
|
201
139
|
|
|
202
|
-
/** Writes the lite provider inbox
|
|
140
|
+
/** Writes the lite provider inbox. Processing belongs to a separately added reactor or job, after the record commits. */
|
|
203
141
|
export function addLiteWebhookInbox({
|
|
204
142
|
root,
|
|
205
143
|
repo,
|
|
@@ -209,6 +147,7 @@ export function addLiteWebhookInbox({
|
|
|
209
147
|
now = Date.now,
|
|
210
148
|
}) {
|
|
211
149
|
if (repo.edition !== "lite" || noun !== "webhook") return [];
|
|
150
|
+
validateProcessingOwner({ root, ...values, provider });
|
|
212
151
|
const Provider = pascalOf(provider);
|
|
213
152
|
const table = `${snakeOf(provider)}_inbox`;
|
|
214
153
|
const migrationsDir = path.join(root, "supabase", "migrations");
|
|
@@ -308,15 +247,7 @@ export function addLiteWebhookInbox({
|
|
|
308
247
|
"HFS_ADD_EXISTS",
|
|
309
248
|
`webhook ${provider} inbox already exists: ${clashes.join(", ")}`,
|
|
310
249
|
);
|
|
311
|
-
const servicePatch = adaptedDomainService({
|
|
312
|
-
root,
|
|
313
|
-
service: values.service,
|
|
314
|
-
serviceModule: values.serviceModule,
|
|
315
|
-
provider,
|
|
316
|
-
Provider,
|
|
317
|
-
});
|
|
318
250
|
const patches = [
|
|
319
|
-
servicePatch,
|
|
320
251
|
...databasePatches(root),
|
|
321
252
|
wiredAppModule({ root, repo, provider, Provider }),
|
|
322
253
|
];
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createSlotResolver, loadSlotManifest, readRepoDeclaration, ruleParams } from '../runtime/scripts/hfs/slots.mjs';
|
|
4
|
+
import { parseYaml } from '../runtime/engine/yaml.mjs';
|
|
5
|
+
import { TEMPLATES_DIR } from '../sync/index.mjs';
|
|
6
|
+
import { ScaffoldError, pascalOf } from './service.mjs';
|
|
7
|
+
import { refuseFullAdapterInLite, refuseInEdition } from './edition-gate.mjs';
|
|
8
|
+
import { selectLiteWebhookEntries } from './add-inbox-lite.mjs';
|
|
9
|
+
import { ensureLiteCli } from './add-cli-lite.mjs';
|
|
10
|
+
|
|
11
|
+
const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
12
|
+
const VARIABLE = /<([A-Za-z][A-Za-z0-9]*)>/g;
|
|
13
|
+
const PLACEHOLDER = /@@([A-Za-z][A-Za-z0-9]*)@@/g;
|
|
14
|
+
const PLATFORM_PREFIX = 'src/modules/platform/';
|
|
15
|
+
const TOPICS_DIR = path.join(import.meta.dirname, '..', 'runtime', 'knowledge', 'patterns', 'be');
|
|
16
|
+
const PATTERN_TEMPLATES = path.join(TEMPLATES_DIR, 'be', 'patterns');
|
|
17
|
+
const camelOf = (name) => { const pascal = pascalOf(name); return pascal[0].toLowerCase() + pascal.slice(1); };
|
|
18
|
+
|
|
19
|
+
/** The noun's app declaration and the ruleParams entry that supplies its topic and options. */
|
|
20
|
+
export function readAddContext(input) {
|
|
21
|
+
const manifest = loadSlotManifest();
|
|
22
|
+
const params = ruleParams(manifest, 'be');
|
|
23
|
+
const spec = params.addKinds[input.noun];
|
|
24
|
+
if (!spec) throw new ScaffoldError('HFS_ADD_NOUN_UNKNOWN', `starci app add knows ${Object.keys(params.addKinds).join(', ')}; ${input.noun} is none of them`);
|
|
25
|
+
const declarationFile = path.join(input.repoRoot, 'hfs.json');
|
|
26
|
+
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)');
|
|
27
|
+
const repo = readRepoDeclaration(manifest, input.repoRoot);
|
|
28
|
+
if (!repo.sides?.be) throw new ScaffoldError('HFS_ADD_NO_BACK_END', 'this app has no back-end side');
|
|
29
|
+
return { ...input, manifest, params, spec, declarationFile, repo };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Lite's built-in migrate and seed groups come from its one CLI bootstrap, without a second instance tree. */
|
|
33
|
+
export function addBuiltinLiteCli({ noun, name, repo, repoRoot, manifest }) {
|
|
34
|
+
if (noun !== 'cli' || repo.edition !== 'lite' || !['migrate', 'seed'].includes(name)) return null;
|
|
35
|
+
if (repo.sides.be.apps.some(app => app.kind === 'cli')) throw new ScaffoldError('HFS_ADD_EXISTS', `the lite cli already carries its built-in ${name} group`);
|
|
36
|
+
return { created: ensureLiteCli({ root: repoRoot, manifest, repo }), registered: { patterns: [], kinds: ['cli'] } };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const readTopic = (topic) => ({ id: topic, doc: parseYaml(fs.readFileSync(path.join(TOPICS_DIR, `${topic}.yaml`), 'utf8')) });
|
|
40
|
+
|
|
41
|
+
function readTree(topic) {
|
|
42
|
+
const file = path.join(TOPICS_DIR, `${topic}.yaml`);
|
|
43
|
+
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`);
|
|
44
|
+
const doc = parseYaml(fs.readFileSync(file, 'utf8'));
|
|
45
|
+
if (!Array.isArray(doc?.files)) throw new ScaffoldError('HFS_ADD_TREE_MISSING', `knowledge/patterns/be/${topic}.yaml has no files tree`);
|
|
46
|
+
return doc.files;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const selectLiteApiEntries = entries => entries.map(entry => {
|
|
50
|
+
if (entry.path === 'src/features/api/<feature>/application/<action>.command.ts') return { ...entry, template: 'api/application.command.principal.ts.tpl' };
|
|
51
|
+
if (entry.path === 'src/features/api/<feature>/application/<action>.handler.ts') return { ...entry, template: 'api/application.handler.principal.ts.tpl' };
|
|
52
|
+
return entry;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** Extra lite api templates stay in the same template set as the pattern topics. */
|
|
56
|
+
export const liteApiTemplates = () => selectLiteApiEntries(readTree('api')).map(entry => entry.template).filter(Boolean);
|
|
57
|
+
|
|
58
|
+
function topicsOfPatterns(patterns) {
|
|
59
|
+
const topics = new Set();
|
|
60
|
+
for (const file of fs.readdirSync(TOPICS_DIR).filter((entry) => entry.endsWith('.yaml'))) {
|
|
61
|
+
const doc = parseYaml(fs.readFileSync(path.join(TOPICS_DIR, file), 'utf8'));
|
|
62
|
+
if (patterns.includes(doc?.pattern)) topics.add(file.slice(0, -'.yaml'.length));
|
|
63
|
+
}
|
|
64
|
+
return topics;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function requiredOption(need, value, noun) {
|
|
68
|
+
if (typeof value !== 'string' || !value) throw new ScaffoldError('HFS_ADD_OPTION_MISSING', `starci app add ${noun} needs --${need}`);
|
|
69
|
+
if (need === 'service') {
|
|
70
|
+
const [service, serviceModule] = value.split('=');
|
|
71
|
+
if (!service || !serviceModule) throw new ScaffoldError('HFS_ADD_OPTION_INVALID', '--service takes <ServiceClass>=<module specifier>');
|
|
72
|
+
return { service, serviceModule };
|
|
73
|
+
}
|
|
74
|
+
if (!KEBAB.test(value)) throw new ScaffoldError('HFS_ADD_OPTION_INVALID', `--${need} ${value} must be kebab-case`);
|
|
75
|
+
return { [need]: value };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function variablesOf({ spec, noun, name, options }) {
|
|
79
|
+
if (!KEBAB.test(name)) throw new ScaffoldError('HFS_ADD_NAME_INVALID', `${noun} name ${name} must be kebab-case (letters, digits and single dashes)`);
|
|
80
|
+
const values = { [spec.variable]: name };
|
|
81
|
+
for (const need of spec.needs ?? []) Object.assign(values, requiredOption(need, options[need], noun));
|
|
82
|
+
for (const [key, rule] of Object.entries(spec.defaults ?? {})) {
|
|
83
|
+
if (values[key] === undefined) values[key] = rule === '@name' ? name : String(rule);
|
|
84
|
+
}
|
|
85
|
+
return values;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function formsOf(values) {
|
|
89
|
+
const forms = {};
|
|
90
|
+
for (const [name, value] of Object.entries(values)) {
|
|
91
|
+
const kebab = KEBAB.test(value);
|
|
92
|
+
forms[name] = value;
|
|
93
|
+
forms[name[0].toUpperCase() + name.slice(1)] = kebab ? pascalOf(value) : value[0].toUpperCase() + value.slice(1);
|
|
94
|
+
forms[`${name}Camel`] = kebab ? camelOf(value) : value[0].toLowerCase() + value.slice(1);
|
|
95
|
+
forms[`${name}Upper`] = kebab ? value.replaceAll('-', '_').toUpperCase() : value;
|
|
96
|
+
forms[`${name}Snake`] = kebab ? value.replaceAll('-', '_') : value;
|
|
97
|
+
}
|
|
98
|
+
return forms;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const fillPath = (text, values) => {
|
|
102
|
+
let missing = false;
|
|
103
|
+
const filled = text.replace(VARIABLE, (whole, name) => (values[name] === undefined ? ((missing = true), whole) : values[name]));
|
|
104
|
+
return missing ? null : filled;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const renderBody = (text, forms, template) => text.replace(PLACEHOLDER, (whole, name) => {
|
|
108
|
+
if (forms[name] === undefined) throw new ScaffoldError('HFS_ADD_PLACEHOLDER_UNKNOWN', `template ${template} uses ${whole}, which no variable of this noun provides`);
|
|
109
|
+
return forms[name];
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/** Wiring may leave a placeholder unchanged; template bodies use the strict rendering refusal above. */
|
|
113
|
+
export const fillKnownForms = (text, forms) => text.replace(PLACEHOLDER, (whole, name) => forms[name] ?? whole);
|
|
114
|
+
|
|
115
|
+
function treesOf({ topics, repo, noun, spec }) {
|
|
116
|
+
const liteApi = noun === 'api' && repo.edition === 'lite';
|
|
117
|
+
return new Map([...topics].map((topic) => {
|
|
118
|
+
const entries = repo.edition === 'lite' ? selectLiteWebhookEntries(readTree(topic)) : readTree(topic);
|
|
119
|
+
return [topic, liteApi && topic === spec.topic ? selectLiteApiEntries(entries) : entries];
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function collectSlots(entries, platform, slots) {
|
|
124
|
+
for (const entry of entries) {
|
|
125
|
+
if (entry.path.startsWith(PLATFORM_PREFIX) === platform && entry.optional !== true && entry.slot) slots.add(entry.slot);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Optional entries gate nothing; the generated instance and platform slots decide the edition. */
|
|
130
|
+
function validateWrittenSlots({ specs, topics, trees, manifest, repo, noun }) {
|
|
131
|
+
const slots = new Set();
|
|
132
|
+
for (const [, spec] of specs) collectSlots(trees.get(spec.topic), false, slots);
|
|
133
|
+
for (const topic of topics) collectSlots(trees.get(topic), true, slots);
|
|
134
|
+
refuseInEdition({ resolver: createSlotResolver(manifest, repo), slotIds: [...slots], command: `add ${noun}` });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function planEntry(entry, { topic, instance, shared, beRoot, withStamp, forms }) {
|
|
138
|
+
if (entry.optional === true) return null;
|
|
139
|
+
const target = fillPath(entry.path, withStamp);
|
|
140
|
+
if (target === null) return null;
|
|
141
|
+
const absolute = path.join(beRoot, ...target.split('/'));
|
|
142
|
+
// A platform capability is whole or absent: its index prevents regeneration of all its files.
|
|
143
|
+
if (!instance && fs.existsSync(path.join(beRoot, ...target.split('/').slice(0, 4), 'index.ts'))) return null;
|
|
144
|
+
if ((!instance || shared) && fs.existsSync(absolute)) return null;
|
|
145
|
+
if (!entry.template) throw new ScaffoldError('HFS_ADD_TEMPLATE_MISSING', `${topic}.yaml lists ${entry.path} without a template, so it cannot be generated`);
|
|
146
|
+
const templateFile = path.join(PATTERN_TEMPLATES, ...entry.template.split('/'));
|
|
147
|
+
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`);
|
|
148
|
+
return { target, absolute, body: renderBody(fs.readFileSync(templateFile, 'utf8'), forms, entry.template), instance };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function appendEntries(entries, context, planned) {
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
const file = planEntry(entry, context);
|
|
154
|
+
if (file) planned.push(file);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Renders all topic-owned paths and bodies before any instance file is written. */
|
|
159
|
+
export function planKind(context) {
|
|
160
|
+
const { noun, name, spec, params, repo, options, now } = context;
|
|
161
|
+
const specs = [noun, ...(spec.also ?? [])].map((each) => [each, params.addKinds[each]]);
|
|
162
|
+
const patterns = [...new Set(specs.flatMap(([, eachSpec]) => eachSpec.patterns))];
|
|
163
|
+
const topics = new Set([...specs.map(([, eachSpec]) => eachSpec.topic), ...topicsOfPatterns(patterns)]);
|
|
164
|
+
refuseFullAdapterInLite({ edition: repo.edition, topics: [...topics].map(readTopic), command: `add ${noun}` });
|
|
165
|
+
const trees = treesOf({ topics, repo, noun, spec });
|
|
166
|
+
validateWrittenSlots({ ...context, specs, topics, trees });
|
|
167
|
+
const values = Object.assign({}, ...specs.map(([each, eachSpec]) => variablesOf({ spec: eachSpec, noun: each, name, options })), variablesOf({ spec, noun, name, options }));
|
|
168
|
+
const withStamp = { ...values, epochMs13: String(now()).padStart(13, '0').slice(0, 13) };
|
|
169
|
+
const forms = formsOf(withStamp);
|
|
170
|
+
const beRoot = path.join(context.repoRoot, 'be');
|
|
171
|
+
const planned = [];
|
|
172
|
+
for (const [each, eachSpec] of specs) {
|
|
173
|
+
const entries = trees.get(eachSpec.topic).filter(entry => !entry.path.startsWith(PLATFORM_PREFIX));
|
|
174
|
+
appendEntries(entries, { topic: eachSpec.topic, instance: true, shared: each !== noun, beRoot, withStamp, forms }, planned);
|
|
175
|
+
}
|
|
176
|
+
for (const topic of topics) {
|
|
177
|
+
const entries = trees.get(topic).filter(entry => entry.path.startsWith(PLATFORM_PREFIX));
|
|
178
|
+
appendEntries(entries, { topic, instance: false, shared: false, beRoot, withStamp, forms }, planned);
|
|
179
|
+
}
|
|
180
|
+
const clash = planned.filter(file => file.instance && fs.existsSync(file.absolute)).map(file => file.target);
|
|
181
|
+
if (clash.length) throw new ScaffoldError('HFS_ADD_EXISTS', `${noun} ${name} already exists: ${clash.join(', ')}`);
|
|
182
|
+
return { values, forms, beRoot, patterns, planned };
|
|
183
|
+
}
|