@starci/hfs 5.1.0 → 5.2.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 +20 -0
- package/README.md +10 -1
- package/db/postgres.mjs +9 -1
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +30 -9
- package/runtime/knowledge/hfs/fe-stack.yaml +7 -7
- package/runtime/knowledge/hfs/fork-images.yaml +7 -0
- package/runtime/knowledge/hfs/rules.yaml +55 -7
- package/runtime/knowledge/hfs/slots.yaml +75 -24
- package/runtime/modules/kernel/failure-codes.yaml +62 -0
- package/runtime/modules/schemas/application-stacks.schema.yaml +6 -0
- package/runtime/scripts/hfs/architecture/identity.mjs +18 -83
- package/runtime/scripts/hfs/architecture/transport-owner.d.mts +1 -2
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +3 -6
- package/runtime/scripts/hfs/manifest-shape.d.mts +1 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +3 -1
- package/runtime/scripts/hfs/rules/a15-findings.mjs +5 -0
- package/runtime/scripts/hfs/rules/stacks-local.d.mts +7 -0
- package/runtime/scripts/hfs/rules/stacks-local.mjs +71 -0
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +6 -1
- package/runtime/scripts/hfs/rules/stacks-service-images.d.mts +12 -0
- package/runtime/scripts/hfs/rules/stacks-service-images.mjs +120 -0
- package/runtime/scripts/hfs/secret-dev-init.d.mts +11 -0
- package/runtime/scripts/hfs/secret-dev-init.mjs +79 -0
- package/runtime/scripts/hfs/secret.mjs +10 -7
- package/runtime/scripts/hfs/slots.mjs +1 -1
- package/runtime/scripts/hfs/stack-local-config.d.mts +101 -0
- package/runtime/scripts/hfs/stack-local-config.mjs +320 -0
- package/runtime/scripts/hfs/stacks-gate.mjs +1 -1
- package/runtime/scripts/lib/swarm-stack.d.mts +2 -1
- package/runtime/scripts/lib/swarm-stack.mjs +2 -2
- package/scaffold/add-module.d.mts +16 -0
- package/scaffold/add-module.mjs +232 -0
- package/scaffold/add-queue-lite.mjs +49 -0
- package/scaffold/add.mjs +5 -0
- package/scaffold/app.mjs +7 -7
- package/scaffold/module-registry.d.mts +19 -0
- package/scaffold/module-registry.mjs +84 -0
- package/src/main.mjs +23 -2
- package/stack/cli.mjs +6 -1
- package/stack/context.d.mts +15 -0
- package/stack/context.mjs +1 -0
- package/stack/local-cli.d.mts +25 -0
- package/stack/local-cli.mjs +89 -0
- package/stack/local-config.d.mts +17 -0
- package/stack/local-config.mjs +37 -0
- package/stack/local-custody.d.mts +24 -0
- package/stack/local-custody.mjs +109 -0
- package/stack/local-doctor.d.mts +6 -0
- package/stack/local-doctor.mjs +49 -0
- package/stack/local-down.d.mts +8 -0
- package/stack/local-down.mjs +20 -0
- package/stack/local-host.d.mts +42 -0
- package/stack/local-host.mjs +163 -0
- package/stack/local-logs.d.mts +8 -0
- package/stack/local-logs.mjs +32 -0
- package/stack/local-ports.d.mts +7 -0
- package/stack/local-ports.mjs +39 -0
- package/stack/local-prepare.d.mts +22 -0
- package/stack/local-prepare.mjs +62 -0
- package/stack/local-run.d.mts +71 -0
- package/stack/local-run.mjs +200 -0
- package/stack/local-state.d.mts +35 -0
- package/stack/local-state.mjs +29 -0
- package/stack/local-status.d.mts +6 -0
- package/stack/local-status.mjs +36 -0
- package/stack/local-up.d.mts +11 -0
- package/stack/local-up.mjs +166 -0
- package/stack/local-verify.d.mts +6 -0
- package/stack/local-verify.mjs +21 -0
- package/sync/index.d.mts +13 -12
- package/sync/index.mjs +83 -28
- package/templates/app/module/jest.config.js.tpl +8 -0
- package/templates/app/module/src/index.ts.tpl +2 -0
- package/templates/app/module/src/manifest/declaration.spec.ts.tpl +11 -0
- package/templates/app/module/src/manifest/declaration.ts.tpl +13 -0
- package/templates/app/module/src/manifest/index.ts.tpl +1 -0
- package/templates/app/module/src/public/index.ts.tpl +2 -0
- package/templates/app/module/src/template/__name__.template.spec.ts.tpl +13 -0
- package/templates/app/module/src/template/__name__.template.ts.tpl +41 -0
- package/templates/app/module/src/template/index.ts.tpl +1 -0
- package/templates/app/module/src/workbench/components/__pascal__WorkbenchView.tsx.tpl +16 -0
- package/templates/app/module/src/workbench/index.ts.tpl +2 -0
- package/templates/app/package-scripts/package.json +1 -1
- package/templates/app/package-scripts-lite/package.json +1 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +76 -70
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +23 -13
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/compose/compose.yaml +25 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -26
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +1 -0
- package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +2 -1
- package/templates/app/skeleton-lite/db/migrations/__timestamp___baseline.sql +57 -14
- package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/get-jwks.handler.ts +23 -0
- package/templates/be/auth-lite/src/features/api/auth/application/get-jwks.query.ts +12 -0
- package/templates/be/auth-lite/src/features/api/auth/application/google-begin.command.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/google-begin.handler.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/application/google-complete.command.ts +12 -0
- package/templates/be/auth-lite/src/features/api/auth/application/google-complete.handler.ts +23 -0
- package/templates/be/auth-lite/src/features/api/auth/application/login.command.ts +12 -0
- package/templates/be/auth-lite/src/features/api/auth/application/login.handler.ts +23 -0
- package/templates/be/auth-lite/src/features/api/auth/application/logout.command.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/logout.handler.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/application/refresh.command.ts +12 -0
- package/templates/be/auth-lite/src/features/api/auth/application/refresh.handler.ts +23 -0
- package/templates/be/auth-lite/src/features/api/auth/application/session.command.ts +12 -0
- package/templates/be/auth-lite/src/features/api/auth/application/session.handler.ts +23 -0
- package/templates/be/auth-lite/src/features/api/auth/auth.module-definition.ts +13 -0
- package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +16 -0
- package/templates/be/auth-lite/src/features/api/auth/auth.options.ts +3 -0
- package/templates/be/auth-lite/src/features/api/auth/index.ts +2 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.module-definition.ts +13 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.module.ts +9 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.options.ts +3 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +90 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/google-start.request.ts +17 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/login.request.ts +16 -0
- package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +46 -0
- package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +28 -0
- package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +145 -0
- package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +95 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-identity.entity.ts +38 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-user.entity.ts +54 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-attempt.entity.ts +18 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-refresh-token.entity.ts +34 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-session.entity.ts +30 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +17 -0
- package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.spec.ts +86 -0
- package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.ts +45 -0
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +97 -0
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +81 -0
- package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +78 -0
- package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +87 -0
- package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +44 -0
- package/templates/be/skeleton-lite/apps/api/src/app.module.ts +6 -5
- package/templates/be/skeleton-lite/apps/api/src/compose/api.ts +3 -2
- package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +10 -5
- package/templates/be/skeleton-lite/src/modules/domain/identity/errors/identity.error.ts +34 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +22 -1
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +47 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/index.ts +2 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/messages/identity.messages.ts +24 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/index.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts +3 -1
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.decorators.ts +8 -4
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.module-definition.ts +12 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.module.ts +29 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts +6 -31
- package/templates/be/skeleton-lite/src/modules/integrations/passport/errors/passport.error.ts +18 -9
- package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +35 -11
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.spec.ts +44 -8
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.ts +40 -18
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.decorators.ts +18 -6
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.kit.spec.ts +73 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.kit.ts +26 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.options.ts +24 -53
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.registration.ts +31 -0
- package/templates/be/skeleton-lite/src/modules/platform/cqrs/index.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +2 -1
- package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -1
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +11 -4
- package/templates/be/test-world/e2e/serves-once-migrated.e2e-spec.ts.tpl +9 -9
- package/templates/be/test-world/fixtures/migrations.sql.ts.tpl +4 -4
- package/templates/fe/auth-lite/apps/web/src/modules/auth/client.ts +26 -0
- package/templates/fe/common/site-shell.tsx.partial +3 -3
- package/templates/fe/skeleton/apps/__app__/src/components/providers/AppProviders/index.tsx +33 -10
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/index.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useViewerId.ts +11 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/toast/toast.tsx +15 -12
- package/templates/fe/skeleton/apps/__app__/src/modules/viewer-cache/index.ts +5 -0
- package/templates/fe/skeleton/apps/__app__/src/redux/slices/session.ts +1 -1
- package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -1
- package/templates/fe/skeleton/packages/__project__-ui/src/composites/NotFoundNotice/index.tsx +4 -7
- package/templates/fe/skeleton/packages/__project__-ui/src/frames/SiteShell/index.tsx +2 -3
- package/templates/fe/skeleton-lite/apps/web/src/components/composites/NotFoundNotice/index.tsx +4 -7
- package/templates/fe/skeleton-lite/apps/web/src/components/frames/SiteShell/index.tsx +1 -2
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/Caddyfile +0 -16
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/owner-actions.md +0 -11
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +0 -7
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/current.yaml +0 -6
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/images.lock.yaml +0 -8
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/previous.yaml +0 -6
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +0 -186
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.spec.ts +0 -22
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts +0 -25
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.spec.ts +0 -104
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.ts +0 -34
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.spec.ts +0 -28
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.ts +0 -11
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts +0 -88
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.ts +0 -69
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +0 -41
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +0 -40
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts +0 -63
- package/templates/fe/skeleton/packages/__project__-ui/src/frames/SiteShell/classNames.ts +0 -4
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/auth-door.ts +0 -30
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +0 -50
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/token-store.ts +0 -31
- package/templates/fe/skeleton-lite/apps/web/src/components/frames/SiteShell/classNames.ts +0 -4
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// starci app add module <name> / starci app remove module <name> - a module package of the product's agent SDK and its registry.
|
|
2
|
+
//
|
|
3
|
+
// A module is a workspace package `packages/module-<name>` (`@<project>/module-<name>`) built on the product's agent SDK (`packages/agent-sdk`): its
|
|
4
|
+
// template and workbench declaration (React-free, for the back end), and its workbench (drawn with Grammar alone). `add` writes the package from
|
|
5
|
+
// templates/app/module, registers it (root `workspaces`, the app dependencies on the registry and the module) and re-renders the managed registry
|
|
6
|
+
// package `packages/modules` (module-registry.mts, the same render `starci app sync` checks); `remove` reverses every one of those steps.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { TEMPLATES_DIR } from '../sync/index.mjs';
|
|
10
|
+
import { readRepoDeclaration, loadSlotManifest } from '../runtime/scripts/hfs/slots.mjs';
|
|
11
|
+
import { byCodeUnit } from '../report/order.mjs';
|
|
12
|
+
import { ScaffoldError, pascalOf, camelOf } from './service.mjs';
|
|
13
|
+
import { packageJsonText } from './app.mjs';
|
|
14
|
+
import { MODULE_PREFIX, REGISTRY_DIR, SDK_DIR, moduleNames, registryFiles, upperOf } from './module-registry.mjs';
|
|
15
|
+
const NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
16
|
+
const TEMPLATE_DIR = path.join(TEMPLATES_DIR, 'app', 'module');
|
|
17
|
+
const WORKSPACE_GLOB = 'packages/*';
|
|
18
|
+
const read = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
19
|
+
const sortedDependencies = (dependencies) => Object.fromEntries(Object.entries(dependencies).sort(([left], [right]) => byCodeUnit(left, right)));
|
|
20
|
+
const listFiles = (dir, base = dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
21
|
+
const full = path.join(dir, entry.name);
|
|
22
|
+
return entry.isDirectory() ? listFiles(full, base) : [path.relative(base, full).split(path.sep).join('/')];
|
|
23
|
+
});
|
|
24
|
+
/** A template's text with `{{project}}`, `{{name}}`, `{{pascal}}`, `{{camel}}` and `{{upper}}` filled; any other placeholder is a defect of the template. */
|
|
25
|
+
function fill(text, values, file) {
|
|
26
|
+
return text.replace(/\{\{([A-Za-z]\w*)\}\}/g, (whole, key) => {
|
|
27
|
+
const value = values[key];
|
|
28
|
+
if (value === undefined)
|
|
29
|
+
throw new ScaffoldError('HFS_ADD_PLACEHOLDER_UNKNOWN', `module template ${file} uses ${whole}, which add module does not provide`);
|
|
30
|
+
return value;
|
|
31
|
+
}).replace(/\r\n/g, '\n');
|
|
32
|
+
}
|
|
33
|
+
/** The package files written from code: the manifests are never template files. */
|
|
34
|
+
function manifests(project, name, versions) {
|
|
35
|
+
const sdk = `@${project}/agent-sdk`;
|
|
36
|
+
const exports = {
|
|
37
|
+
'.': { types: './dist/index.d.ts', default: './dist/index.js' },
|
|
38
|
+
'./template': { types: './dist/template/index.d.ts', default: './dist/template/index.js' },
|
|
39
|
+
'./manifest': { types: './dist/manifest/index.d.ts', default: './dist/manifest/index.js' },
|
|
40
|
+
'./workbench': { types: './dist/esm/workbench/index.d.ts', default: './dist/esm/workbench/index.js' },
|
|
41
|
+
'./public': { types: './dist/esm/public/index.d.ts', default: './dist/esm/public/index.js' },
|
|
42
|
+
};
|
|
43
|
+
const packageJson = {
|
|
44
|
+
name: `@${project}/${MODULE_PREFIX}${name}`,
|
|
45
|
+
version: '0.0.0',
|
|
46
|
+
private: true,
|
|
47
|
+
description: `The ${pascalOf(name)} module on the ${project} agent SDK: its template (./template) and workbench declaration (./manifest), both React-free for the back end, its Grammar-only workbench (./workbench) and its public face (./public) for the front end.`,
|
|
48
|
+
exports,
|
|
49
|
+
scripts: { build: 'tsc -p tsconfig.build.json && tsc -p tsconfig.build.workbench.json', typecheck: 'tsc --noEmit --pretty false', test: 'jest --coverage' },
|
|
50
|
+
dependencies: { [sdk]: '*' },
|
|
51
|
+
peerDependencies: { '@starci/grammar': versions['@starci/grammar'], react: versions['react'] },
|
|
52
|
+
devDependencies: sortedDependencies({ '@types/jest': versions['@types/jest'] ?? '', '@types/react': versions['@types/react'] ?? '', jest: versions['jest'] ?? '', 'ts-jest': versions['ts-jest'] ?? '', typescript: versions['typescript'] ?? '' }),
|
|
53
|
+
};
|
|
54
|
+
const build = { rootDir: 'src', noEmit: false, declaration: true, types: [] };
|
|
55
|
+
return [
|
|
56
|
+
{ relative: 'package.json', body: packageJsonText(packageJson) },
|
|
57
|
+
{ relative: 'tsconfig.json', body: packageJsonText({ extends: '@starci/tsconfig/next.json', compilerOptions: { incremental: false, types: ['jest', 'node'] }, include: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.json'], exclude: ['node_modules', 'dist'] }) },
|
|
58
|
+
{ relative: 'tsconfig.build.json', body: packageJsonText({ extends: './tsconfig.json', compilerOptions: { ...build, outDir: 'dist', module: 'node16', moduleResolution: 'node16' }, exclude: ['node_modules', 'dist', 'src/**/*.spec.ts', 'src/workbench/**', 'src/public/**'] }) },
|
|
59
|
+
{ relative: 'tsconfig.build.workbench.json', body: packageJsonText({ extends: './tsconfig.json', compilerOptions: { ...build, outDir: 'dist/esm' }, include: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.json'], exclude: ['node_modules', 'dist', 'src/**/*.spec.ts'] }) },
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
/** The versions a module package declares, read from the app's own manifests so it installs the one version of each. */
|
|
63
|
+
function versionsOf(root, feAppFiles) {
|
|
64
|
+
const rootPackage = read(path.join(root, 'package.json'));
|
|
65
|
+
const all = { ...rootPackage.devDependencies, ...rootPackage.dependencies };
|
|
66
|
+
for (const file of feAppFiles)
|
|
67
|
+
Object.assign(all, read(file).dependencies);
|
|
68
|
+
const wanted = ['@starci/grammar', 'react', '@types/jest', '@types/react', 'jest', 'ts-jest', 'typescript'];
|
|
69
|
+
const missing = wanted.filter((name) => all[name] === undefined);
|
|
70
|
+
if (missing.length > 0)
|
|
71
|
+
throw new ScaffoldError('HFS_ADD_MODULE_PINS', `add module reads the versions of ${missing.join(', ')} from the app's manifests, which declare none of them`);
|
|
72
|
+
return Object.fromEntries(wanted.map((name) => [name, all[name]]));
|
|
73
|
+
}
|
|
74
|
+
/** The package.json of every front-end Next app of the app. */
|
|
75
|
+
const feManifests = (root) => {
|
|
76
|
+
const apps = path.join(root, 'fe', 'apps');
|
|
77
|
+
if (!fs.existsSync(apps))
|
|
78
|
+
return [];
|
|
79
|
+
return fs.readdirSync(apps, { withFileTypes: true }).filter((entry) => entry.isDirectory() && fs.existsSync(path.join(apps, entry.name, 'package.json'))).map((entry) => path.join(apps, entry.name, 'package.json')).sort(byCodeUnit);
|
|
80
|
+
};
|
|
81
|
+
/** Everything a verb changes, snapshotted first so a failure leaves the tree as it was. */
|
|
82
|
+
class Transaction {
|
|
83
|
+
before = new Map();
|
|
84
|
+
root;
|
|
85
|
+
constructor(root) { this.root = root; }
|
|
86
|
+
write(relative, body) {
|
|
87
|
+
const file = path.join(this.root, ...relative.split('/'));
|
|
88
|
+
if (!this.before.has(file))
|
|
89
|
+
this.before.set(file, fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null);
|
|
90
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
91
|
+
fs.writeFileSync(file, body);
|
|
92
|
+
}
|
|
93
|
+
remove(relative) {
|
|
94
|
+
const file = path.join(this.root, ...relative.split('/'));
|
|
95
|
+
if (!fs.existsSync(file))
|
|
96
|
+
return;
|
|
97
|
+
for (const inner of fs.statSync(file).isDirectory() ? listFiles(file).map((name) => path.join(file, ...name.split('/'))) : [file])
|
|
98
|
+
this.before.set(inner, fs.readFileSync(inner, 'utf8'));
|
|
99
|
+
fs.rmSync(file, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
rollback() {
|
|
102
|
+
for (const [file, text] of this.before) {
|
|
103
|
+
if (text === null)
|
|
104
|
+
fs.rmSync(file, { force: true });
|
|
105
|
+
else {
|
|
106
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
107
|
+
fs.writeFileSync(file, text);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function context(root, name) {
|
|
113
|
+
if (!NAME.test(String(name)))
|
|
114
|
+
throw new ScaffoldError('HFS_ADD_NAME_INVALID', `module name ${name} must be kebab-case (letters, digits and single dashes)`);
|
|
115
|
+
const declarationFile = path.join(root, 'hfs.json');
|
|
116
|
+
const packageFile = path.join(root, 'package.json');
|
|
117
|
+
if (!fs.existsSync(declarationFile) || !fs.existsSync(packageFile))
|
|
118
|
+
throw new ScaffoldError('HFS_ADD_NOT_AN_APP', 'a module verb runs at the app root containing hfs.json and package.json');
|
|
119
|
+
const repo = readRepoDeclaration(loadSlotManifest(), root);
|
|
120
|
+
return { project: repo.project, declarationFile, packageFile };
|
|
121
|
+
}
|
|
122
|
+
/** Sets or deletes one dependency of a manifest file, keeping the section sorted; answers whether the text changed. */
|
|
123
|
+
function setDependency(tx, root, file, name, version) {
|
|
124
|
+
const manifest = read(file);
|
|
125
|
+
const current = manifest.dependencies ?? {};
|
|
126
|
+
if ((version === null && current[name] === undefined) || current[name] === version)
|
|
127
|
+
return false;
|
|
128
|
+
const next = { ...current };
|
|
129
|
+
if (version === null)
|
|
130
|
+
Reflect.deleteProperty(next, name);
|
|
131
|
+
else
|
|
132
|
+
next[name] = version;
|
|
133
|
+
manifest.dependencies = sortedDependencies(next);
|
|
134
|
+
tx.write(path.relative(root, file).split(path.sep).join('/'), packageJsonText(manifest));
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
/** Registers the modules on the app: the workspace glob, and the registry and module dependencies of the back end (the root manifest) and every front-end app. */
|
|
138
|
+
function register(tx, root, project, packageFile, names, changed) {
|
|
139
|
+
const rootPackage = read(packageFile);
|
|
140
|
+
const workspaces = new Set(rootPackage.workspaces ?? []);
|
|
141
|
+
const others = fs.existsSync(path.join(root, 'packages')) && fs.readdirSync(path.join(root, 'packages')).some((entry) => !entry.startsWith(MODULE_PREFIX) && entry !== 'modules');
|
|
142
|
+
if (names.length > 0)
|
|
143
|
+
workspaces.add(WORKSPACE_GLOB);
|
|
144
|
+
else if (!others)
|
|
145
|
+
workspaces.delete(WORKSPACE_GLOB);
|
|
146
|
+
if ([...workspaces].join('|') !== (rootPackage.workspaces ?? []).join('|')) {
|
|
147
|
+
rootPackage.workspaces = [...workspaces];
|
|
148
|
+
tx.write('package.json', packageJsonText(rootPackage));
|
|
149
|
+
changed.push('package.json');
|
|
150
|
+
}
|
|
151
|
+
const registry = `@${project}/modules`;
|
|
152
|
+
if (setDependency(tx, root, packageFile, registry, names.length > 0 ? '*' : null))
|
|
153
|
+
changed.push('package.json');
|
|
154
|
+
for (const file of feManifests(root)) {
|
|
155
|
+
let touched = setDependency(tx, root, file, registry, names.length > 0 ? '*' : null);
|
|
156
|
+
const present = Object.keys(read(file).dependencies ?? {}).filter((dependency) => dependency.startsWith(`@${project}/${MODULE_PREFIX}`));
|
|
157
|
+
for (const dependency of present)
|
|
158
|
+
if (!names.some((name) => `@${project}/${MODULE_PREFIX}${name}` === dependency))
|
|
159
|
+
touched = setDependency(tx, root, file, dependency, null) || touched;
|
|
160
|
+
for (const name of names)
|
|
161
|
+
touched = setDependency(tx, root, file, `@${project}/${MODULE_PREFIX}${name}`, '*') || touched;
|
|
162
|
+
if (touched)
|
|
163
|
+
changed.push(path.relative(root, file).split(path.sep).join('/'));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Replaces the registry package by the render of `names`: files written, and a registry of no module deleted. */
|
|
167
|
+
function writeRegistry(tx, root, project, names, result) {
|
|
168
|
+
const files = registryFiles(project, names);
|
|
169
|
+
if (files.length === 0) {
|
|
170
|
+
if (fs.existsSync(path.join(root, REGISTRY_DIR))) {
|
|
171
|
+
tx.remove(REGISTRY_DIR);
|
|
172
|
+
result.removed.push(REGISTRY_DIR);
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
for (const file of files) {
|
|
177
|
+
(fs.existsSync(path.join(root, file.path)) ? result.updated : result.created).push(file.path);
|
|
178
|
+
tx.write(file.path, file.content);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** Adds the module package `name`, registers it and re-renders the registry. Refused when the app has no agent SDK package or the module exists. */
|
|
182
|
+
export function addModule({ root, name }) {
|
|
183
|
+
const { project, packageFile } = context(root, name);
|
|
184
|
+
if (!fs.existsSync(path.join(root, SDK_DIR, 'package.json')))
|
|
185
|
+
throw new ScaffoldError('HFS_ADD_MODULE_SDK_MISSING', `a module is built on the product's agent SDK, which must exist as ${SDK_DIR}/ (a workspace package named @${project}/agent-sdk)`);
|
|
186
|
+
const folder = `packages/${MODULE_PREFIX}${name}`;
|
|
187
|
+
if (fs.existsSync(path.join(root, folder)))
|
|
188
|
+
throw new ScaffoldError('HFS_ADD_EXISTS', `module ${name} already exists: ${folder}`);
|
|
189
|
+
const values = { project, name, pascal: pascalOf(name), camel: camelOf(name), upper: upperOf(name) };
|
|
190
|
+
const planned = [
|
|
191
|
+
...manifests(project, name, versionsOf(root, feManifests(root))),
|
|
192
|
+
...listFiles(TEMPLATE_DIR).map((file) => ({
|
|
193
|
+
relative: file.replace(/\.tpl$/, '').split('/').map((segment) => segment.replace('__name__', name).replace('__pascal__', values.pascal)).join('/'),
|
|
194
|
+
body: fill(fs.readFileSync(path.join(TEMPLATE_DIR, ...file.split('/')), 'utf8'), values, file),
|
|
195
|
+
})),
|
|
196
|
+
];
|
|
197
|
+
const result = { created: planned.map((file) => `${folder}/${file.relative}`), updated: [], removed: [] };
|
|
198
|
+
const tx = new Transaction(root);
|
|
199
|
+
try {
|
|
200
|
+
for (const file of planned)
|
|
201
|
+
tx.write(`${folder}/${file.relative}`, file.body);
|
|
202
|
+
const names = moduleNames(root);
|
|
203
|
+
register(tx, root, project, packageFile, names, result.updated);
|
|
204
|
+
writeRegistry(tx, root, project, names, result);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
tx.rollback();
|
|
208
|
+
fs.rmSync(path.join(root, folder), { recursive: true, force: true });
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
/** Removes the module package `name`, its registrations and its entry in the registry; the last module takes the registry package with it. */
|
|
214
|
+
export function removeModule({ root, name }) {
|
|
215
|
+
const { project, packageFile } = context(root, name);
|
|
216
|
+
const folder = `packages/${MODULE_PREFIX}${name}`;
|
|
217
|
+
if (!fs.existsSync(path.join(root, folder)))
|
|
218
|
+
throw new ScaffoldError('HFS_REMOVE_NOT_FOUND', `module ${name} does not exist: ${folder}`);
|
|
219
|
+
const result = { created: [], updated: [], removed: [folder] };
|
|
220
|
+
const tx = new Transaction(root);
|
|
221
|
+
try {
|
|
222
|
+
tx.remove(folder);
|
|
223
|
+
const names = moduleNames(root);
|
|
224
|
+
register(tx, root, project, packageFile, names, result.updated);
|
|
225
|
+
writeRegistry(tx, root, project, names, result);
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
tx.rollback();
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
@@ -300,6 +300,54 @@ function keysPatch(file) {
|
|
|
300
300
|
lines.splice(last + 1, 0, ...KEY_ROWS);
|
|
301
301
|
return { file, text: lines.join('\n') };
|
|
302
302
|
}
|
|
303
|
+
const DEV_REDIS_PORT = 56379;
|
|
304
|
+
/** The redis service a dev compose file gains: loopback only, append-only, on the host port the environment declares. */
|
|
305
|
+
const devRedisService = ` redis:
|
|
306
|
+
image: ${REDIS_IMAGE}
|
|
307
|
+
command: ["redis-server", "--appendonly", "yes"]
|
|
308
|
+
ports:
|
|
309
|
+
- "127.0.0.1:\${STACK_PORT_REDIS}:6379"
|
|
310
|
+
volumes:
|
|
311
|
+
- redis-data:/data
|
|
312
|
+
healthcheck:
|
|
313
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
314
|
+
interval: 5s
|
|
315
|
+
timeout: 3s
|
|
316
|
+
retries: 20
|
|
317
|
+
restart: unless-stopped
|
|
318
|
+
`;
|
|
319
|
+
/**
|
|
320
|
+
* A docker-compose dev environment (runtime docker-compose, a `local` block) runs the declared Redis from its compose file, on its own loopback
|
|
321
|
+
* port, and the queue processes reach it there: the compose service, the port, the service entry and the api and worker processes' queue settings.
|
|
322
|
+
*/
|
|
323
|
+
function devQueuePatches(repoRoot, manifest, edit, consumers) {
|
|
324
|
+
const patches = [];
|
|
325
|
+
const dev = Object.entries(manifest?.environments ?? {}).filter(([, environment]) => environment?.runtime === 'docker-compose' && environment.local);
|
|
326
|
+
for (const [name, environment] of dev) {
|
|
327
|
+
const composeRelative = `.starcistacks/${name}/${environment.composeFiles?.[0] ?? 'infra/compose/compose.yaml'}`;
|
|
328
|
+
const composeFile = path.join(repoRoot, ...composeRelative.split('/'));
|
|
329
|
+
const compose = read(composeFile).replace(/\r\n/g, '\n');
|
|
330
|
+
if (/^ {2}redis:/m.test(compose))
|
|
331
|
+
continue;
|
|
332
|
+
const base = ['environments', name];
|
|
333
|
+
const value = edit.value.environments[name];
|
|
334
|
+
edit.append([...base, 'components'], `redis: {port: ${DEV_REDIS_PORT}}\n`);
|
|
335
|
+
value.components.redis = { port: DEV_REDIS_PORT };
|
|
336
|
+
edit.append([...base, 'local', 'ports'], `redis: ${DEV_REDIS_PORT}\n`);
|
|
337
|
+
value.local.ports.redis = DEV_REDIS_PORT;
|
|
338
|
+
edit.append([...base, 'local', 'services'], '- {name: redis, wait: healthy}\n');
|
|
339
|
+
value.local.services.push({ name: 'redis', wait: 'healthy' });
|
|
340
|
+
for (const run of value.local.processes ?? []) {
|
|
341
|
+
if (!consumers.includes(run.name))
|
|
342
|
+
continue;
|
|
343
|
+
edit.append([...base, 'local', 'processes', { item: 'name', is: run.name }, 'env'], 'QUEUE_ENABLED: "true"\nQUEUE_REDIS_HOST: 127.0.0.1\nQUEUE_REDIS_PORT: "${port.redis}"\n');
|
|
344
|
+
Object.assign(run.env, { QUEUE_ENABLED: 'true', QUEUE_REDIS_HOST: '127.0.0.1', QUEUE_REDIS_PORT: '${port.redis}' });
|
|
345
|
+
}
|
|
346
|
+
const head = compose.includes('\nvolumes:\n') ? compose.replace('\nvolumes:\n', `\n${devRedisService}volumes:\n`) : `${compose.replace(/\n*$/, '\n')}${devRedisService}volumes:\n`;
|
|
347
|
+
patches.push({ file: composeFile, text: `${head.replace(/\n*$/, '\n')} redis-data: {}\n` });
|
|
348
|
+
}
|
|
349
|
+
return patches;
|
|
350
|
+
}
|
|
303
351
|
/** Every stack file the declared Redis adds to or changes, planned before anything is written. */
|
|
304
352
|
function stackFiles(repoRoot, { project, consumers }) {
|
|
305
353
|
const manifestRelative = '.starcistacks/application-stacks.yaml';
|
|
@@ -330,6 +378,7 @@ function stackFiles(repoRoot, { project, consumers }) {
|
|
|
330
378
|
if (!fs.existsSync(path.join(repoRoot, ...recovery.split('/'))))
|
|
331
379
|
created.push({ relative: recovery, text: recoveryRecord });
|
|
332
380
|
}
|
|
381
|
+
patches.push(...devQueuePatches(repoRoot, manifest, edit, consumers));
|
|
333
382
|
patches.unshift({ file: manifestFile, text: edit.text() });
|
|
334
383
|
return { patches: patches.filter((patch) => patch !== null), created, environments: swarm.map(([name]) => name) };
|
|
335
384
|
}
|
package/scaffold/add.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { ScaffoldError, pascalOf } from './service.mjs';
|
|
|
23
23
|
import { addTable, graphqlPlatformFiles } from './add-table.mjs';
|
|
24
24
|
import { addModuleImport, compositionCode, readComposeGroup, writeComposeGroup } from './add-compose.mjs';
|
|
25
25
|
import { addApp } from './add-app.mjs';
|
|
26
|
+
import { addModule } from './add-module.mjs';
|
|
26
27
|
import { FE_UNIT_LAYERS, addFeUnit } from './add-fe-unit.mjs';
|
|
27
28
|
import { addFePage } from './add-fe-page.mjs';
|
|
28
29
|
import { addFeOverlay } from './add-fe-overlay.mjs';
|
|
@@ -57,6 +58,10 @@ export function addKind({ repoRoot, noun, name, options = {}, now = Date.now })
|
|
|
57
58
|
const result = addApp({ root: repoRoot, name });
|
|
58
59
|
return { ...result, registered: { patterns: [], kinds: [] } };
|
|
59
60
|
}
|
|
61
|
+
if (noun === 'module') {
|
|
62
|
+
const result = addModule({ root: repoRoot, name });
|
|
63
|
+
return { created: [...result.created, ...result.updated], registered: { patterns: [], kinds: [] } };
|
|
64
|
+
}
|
|
60
65
|
const feOwner = FE_OWNERS[noun];
|
|
61
66
|
if (feOwner !== undefined || Object.hasOwn(FE_UNIT_LAYERS, noun)) {
|
|
62
67
|
const result = feOwner === undefined ? addFeUnit({ repoRoot, noun, name, options }) : feOwner({ repoRoot, name, options });
|
package/scaffold/app.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
18
18
|
import { loadSlotManifest, resolveAppDeclaration } from '../runtime/scripts/hfs/slots.mjs';
|
|
19
19
|
import { parseYaml } from '../runtime/engine/yaml.mjs';
|
|
20
20
|
import { HARNESS_PACKAGE, canonPins } from '../runtime/scripts/hfs/canon-pins.mjs';
|
|
21
|
-
import { TEMPLATES_DIR, appSource, imageFiles, render, renderTargets, writeTargets } from '../sync/index.mjs';
|
|
21
|
+
import { TEMPLATES_DIR, appSource, imageFiles, render, renderTargets, settleSource, writeTargets } from '../sync/index.mjs';
|
|
22
22
|
import { ScaffoldError } from './service.mjs';
|
|
23
23
|
import { testWorldFiles } from './test-world.mjs';
|
|
24
24
|
import { FE_APP_SCRIPTS, PACKAGE_MANAGER, WORKSPACES, feAppPackageName } from '../runtime/scripts/hfs/rules/monorepo.mjs';
|
|
@@ -158,15 +158,14 @@ const STARTER_DEPENDENCIES = Object.freeze({
|
|
|
158
158
|
const LITE_STARTER_DEPENDENCIES = Object.freeze({
|
|
159
159
|
dependencies: {
|
|
160
160
|
'@apollo/server': null, '@nestjs/apollo': null, '@nestjs/common': null, '@nestjs/core': null, '@nestjs/cqrs': null, '@nestjs/graphql': null,
|
|
161
|
-
'@nestjs/platform-express': null, '@nestjs/typeorm': null, '@
|
|
162
|
-
'
|
|
163
|
-
'class-transformer': null, 'class-validator': null, graphql: null, jose: null, pg: null,
|
|
161
|
+
'@nestjs/platform-express': null, '@nestjs/typeorm': null, '@starci/auth': null, '@starci/object-storage': null,
|
|
162
|
+
'class-transformer': null, 'class-validator': null, graphql: null, pg: null,
|
|
164
163
|
'reflect-metadata': null, rxjs: null, tslib: null, typeorm: null, undici: null, zod: null,
|
|
165
164
|
},
|
|
166
165
|
devDependencies: {
|
|
167
166
|
turbo: null, '@nestjs/testing': null, '@starci/eslint-canon-be': null, '@starci/eslint-canon-fe': null,
|
|
168
167
|
'@starci/jest-preset': null, '@starci/prettier-config': null, '@starci/stylelint-canon': null, '@starci/tsconfig': null, 'starci-harness': null, '@tailwindcss/postcss': null,
|
|
169
|
-
'@types/express': null, '@types/jest': null, '@types/node': null, '@types/
|
|
168
|
+
'@types/express': null, '@types/jest': null, '@types/node': null, '@types/react': null, '@types/react-dom': null,
|
|
170
169
|
eslint: null, 'eslint-plugin-react-hooks': null, husky: null, jest: null, 'postcss-value-parser': null, prettier: null,
|
|
171
170
|
stylelint: null, tailwindcss: null, 'ts-jest': null, 'ts-node-dev': null, 'tsc-alias': null,
|
|
172
171
|
'tsconfig-paths': null, typescript: null,
|
|
@@ -187,7 +186,7 @@ const FE_APP_DEPENDENCIES = Object.freeze({
|
|
|
187
186
|
* holds no identity SDK: modules/auth talks only to the app's own back-end auth doors.
|
|
188
187
|
*/
|
|
189
188
|
const LITE_FE_APP_DEPENDENCIES = Object.freeze({
|
|
190
|
-
'@heroicons/react': null, '@heroui/react': null, '@heroui/styles': null, '@starci/grammar': null, '@starci/heroicons': null, '@xyflow/react': null, 'react-aria-components': null,
|
|
189
|
+
'@heroicons/react': null, '@heroui/react': null, '@heroui/styles': null, '@starci/auth': null, '@starci/grammar': null, '@starci/heroicons': null, '@xyflow/react': null, 'react-aria-components': null,
|
|
191
190
|
next: null, 'next-intl': null, react: null, 'react-dom': null, 'server-only': null,
|
|
192
191
|
});
|
|
193
192
|
/**
|
|
@@ -656,7 +655,8 @@ export function scaffoldApp({ name, into, presets, harness, edition = 'full', re
|
|
|
656
655
|
try {
|
|
657
656
|
for (const file of files)
|
|
658
657
|
writes.write(file.path, file.content);
|
|
659
|
-
const
|
|
658
|
+
const source = appSource(root);
|
|
659
|
+
const targets = renderTargets(declaration, presets, { manifest, source: settleSource(source, renderTargets(declaration, presets, { manifest, source })) });
|
|
660
660
|
for (const target of targets)
|
|
661
661
|
writes.claim(target.path);
|
|
662
662
|
writeTargets(root, targets);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** The folder name prefix of a module package. */
|
|
2
|
+
export declare const MODULE_PREFIX = "module-";
|
|
3
|
+
/** The package that lists every module package. */
|
|
4
|
+
export declare const REGISTRY_DIR = "packages/modules";
|
|
5
|
+
/** The product's agent SDK package folder every module builds on. */
|
|
6
|
+
export declare const SDK_DIR = "packages/agent-sdk";
|
|
7
|
+
/** A file of the registry package: its app-relative path and its content. */
|
|
8
|
+
export interface RegistryFile {
|
|
9
|
+
readonly path: string;
|
|
10
|
+
readonly content: string;
|
|
11
|
+
}
|
|
12
|
+
/** `loyalty-points` as the UPPER_SNAKE prefix of the constants a module exports. */
|
|
13
|
+
export declare const upperOf: (name: string) => string;
|
|
14
|
+
/** The module names under `packages/` of the app at `root` (a folder module-<name> holding a package.json), sorted. */
|
|
15
|
+
export declare function moduleNames(root: string): string[];
|
|
16
|
+
/** The files of the registry package for `names` (empty when no module exists: the package is then absent). */
|
|
17
|
+
export declare function registryFiles(project: string, names: readonly string[]): RegistryFile[];
|
|
18
|
+
/** The PascalCase label of a module, for the messages that name it. */
|
|
19
|
+
export declare const labelOf: (name: string) => string;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// module-registry.mts - the module packages of an app (packages/module-<name>) and the registry package (packages/modules) that lists them.
|
|
2
|
+
//
|
|
3
|
+
// The registry is managed: its manifests and its three source files are rendered from the module list, by `starci app add|remove module` and by
|
|
4
|
+
// `starci app sync` (the same function), so a hand edit is drift. A module package is written once by `add module` and is the product's from then on.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { byCodeUnit } from '../report/order.mjs';
|
|
8
|
+
import { pascalOf } from './service.mjs';
|
|
9
|
+
/** The folder name prefix of a module package. */
|
|
10
|
+
export const MODULE_PREFIX = 'module-';
|
|
11
|
+
/** The package that lists every module package. */
|
|
12
|
+
export const REGISTRY_DIR = 'packages/modules';
|
|
13
|
+
/** The product's agent SDK package folder every module builds on. */
|
|
14
|
+
export const SDK_DIR = 'packages/agent-sdk';
|
|
15
|
+
const HEADER = '// Generated by starci app sync (module registry). Do not edit: run "npx starci app sync --execute".';
|
|
16
|
+
const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
17
|
+
/** `loyalty-points` as the UPPER_SNAKE prefix of the constants a module exports. */
|
|
18
|
+
export const upperOf = (name) => name.replace(/-/g, '_').toUpperCase();
|
|
19
|
+
/** The module names under `packages/` of the app at `root` (a folder module-<name> holding a package.json), sorted. */
|
|
20
|
+
export function moduleNames(root) {
|
|
21
|
+
const folder = path.join(root, 'packages');
|
|
22
|
+
if (!fs.existsSync(folder))
|
|
23
|
+
return [];
|
|
24
|
+
return fs.readdirSync(folder, { withFileTypes: true })
|
|
25
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith(MODULE_PREFIX) && fs.existsSync(path.join(folder, entry.name, 'package.json')))
|
|
26
|
+
.map((entry) => entry.name.slice(MODULE_PREFIX.length))
|
|
27
|
+
.sort(byCodeUnit);
|
|
28
|
+
}
|
|
29
|
+
/** The exports map of the registry and of a module: the React-free halves and, for a module, its Grammar workbench. */
|
|
30
|
+
const registryExports = () => ({
|
|
31
|
+
'.': { types: './dist/index.d.ts', default: './dist/index.js' },
|
|
32
|
+
'./template': { types: './dist/template/index.d.ts', default: './dist/template/index.js' },
|
|
33
|
+
'./manifest': { types: './dist/manifest/index.d.ts', default: './dist/manifest/index.js' },
|
|
34
|
+
});
|
|
35
|
+
/** The files of the registry package for `names` (empty when no module exists: the package is then absent). */
|
|
36
|
+
export function registryFiles(project, names) {
|
|
37
|
+
if (names.length === 0)
|
|
38
|
+
return [];
|
|
39
|
+
const sorted = [...names].sort(byCodeUnit);
|
|
40
|
+
const sdk = `@${project}/agent-sdk`;
|
|
41
|
+
const module = (name) => `@${project}/${MODULE_PREFIX}${name}`;
|
|
42
|
+
const serverImports = sorted.map((name) => `import { ${upperOf(name)}_SERVER } from "${module(name)}/template"`).join('\n');
|
|
43
|
+
const workbenchImports = sorted.map((name) => `import { ${upperOf(name)}_WORKBENCH } from "${module(name)}/manifest"`).join('\n');
|
|
44
|
+
const dependencies = Object.fromEntries([[sdk, '*'], ...sorted.map((name) => [module(name), '*'])].sort(([a], [b]) => byCodeUnit(a, b)));
|
|
45
|
+
return [
|
|
46
|
+
{
|
|
47
|
+
path: `${REGISTRY_DIR}/package.json`,
|
|
48
|
+
content: json({
|
|
49
|
+
name: `@${project}/modules`,
|
|
50
|
+
version: '0.0.0',
|
|
51
|
+
private: true,
|
|
52
|
+
description: 'The one list of the product modules built on the agent SDK, all React-free: ./template (every module\'s server half, for the back end) and ./manifest (every workbench declaration and the spec registry they assemble).',
|
|
53
|
+
exports: registryExports(),
|
|
54
|
+
scripts: { build: 'tsc -p tsconfig.build.json', typecheck: 'tsc --noEmit --pretty false' },
|
|
55
|
+
dependencies,
|
|
56
|
+
}),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
path: `${REGISTRY_DIR}/tsconfig.json`,
|
|
60
|
+
content: json({ extends: '@starci/tsconfig/next.json', compilerOptions: { incremental: false, types: [] }, include: ['src/**/*.ts'], exclude: ['node_modules', 'dist'] }),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
path: `${REGISTRY_DIR}/tsconfig.build.json`,
|
|
64
|
+
content: json({
|
|
65
|
+
extends: './tsconfig.json',
|
|
66
|
+
compilerOptions: { rootDir: 'src', outDir: 'dist', noEmit: false, declaration: true, module: 'node16', moduleResolution: 'node16' },
|
|
67
|
+
}),
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
path: `${REGISTRY_DIR}/src/index.ts`,
|
|
71
|
+
content: `${HEADER}\nimport { MODULE_SERVERS } from "./template"\n\n/** The keys of every module this registry lists, for a host that checks its own wiring against it. */\nexport const REGISTERED_MODULE_KEYS: ReadonlyArray<string> = MODULE_SERVERS.map((module) => module.key)\n`,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
path: `${REGISTRY_DIR}/src/template/index.ts`,
|
|
75
|
+
content: `${HEADER}\nimport type { ModuleServer } from "${sdk}/module"\n${serverImports}\n\n/** The server half of every module built on the SDK; the back end reads nothing else of a module. */\nexport const MODULE_SERVERS: ReadonlyArray<ModuleServer> = [${sorted.map((name) => `${upperOf(name)}_SERVER`).join(', ')}]\n`,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
path: `${REGISTRY_DIR}/src/manifest/index.ts`,
|
|
79
|
+
content: `${HEADER}\nimport { specRegistryFrom } from "${sdk}/workbench/contract"\nimport type { WorkbenchDeclaration, WorkbenchSpecRegistry } from "${sdk}/workbench/contract"\n${workbenchImports}\n\n/** The workbench declaration of every module built on the SDK; the front end pairs each with how it draws. */\nexport const MODULE_WORKBENCHES: ReadonlyArray<WorkbenchDeclaration> = [${sorted.map((name) => `${upperOf(name)}_WORKBENCH`).join(', ')}]\n\n/** Everything a stored spec may name: the SDK's blocks and each module's exposed blocks, reads and actions. */\nexport const SPEC_REGISTRY: WorkbenchSpecRegistry = specRegistryFrom(MODULE_WORKBENCHES)\n`,
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
/** The PascalCase label of a module, for the messages that name it. */
|
|
84
|
+
export const labelOf = (name) => pascalOf(name);
|
package/src/main.mjs
CHANGED
|
@@ -101,6 +101,7 @@ import { emitContractsAsync } from '../emit/contracts.mjs';
|
|
|
101
101
|
import { ScaffoldError, newService, newSpec } from '../scaffold/service.mjs';
|
|
102
102
|
import { EditionRefusal } from '../scaffold/edition-gate.mjs';
|
|
103
103
|
import { addKind } from '../scaffold/add.mjs';
|
|
104
|
+
import { removeModule } from '../scaffold/add-module.mjs';
|
|
104
105
|
import { scaffoldApp } from '../scaffold/app.mjs';
|
|
105
106
|
import { newImages } from '../scaffold/image.mjs';
|
|
106
107
|
import { cachedLinters, eslintRepository, runLinter as nativeLinter } from '../lint/run.mjs';
|
|
@@ -140,7 +141,7 @@ const listFlag = (opts, flag) => { const given = opts[flag]; return Array.isArra
|
|
|
140
141
|
/** An argument the parser refuses (an unknown flag, a missing value, a form a verb does not take): the one error `main` follows with the usage. */
|
|
141
142
|
export class UsageError extends Error {
|
|
142
143
|
}
|
|
143
|
-
export const VERBS = Object.freeze(['scaffold', 'add', 'sync', 'check', 'upgrade', 'stack', 'world', 'release', 'slot', 'emit', 'secret', 'ci', 'db', 'coverage', 'codemod']);
|
|
144
|
+
export const VERBS = Object.freeze(['scaffold', 'add', 'remove', 'sync', 'check', 'upgrade', 'stack', 'world', 'release', 'slot', 'emit', 'secret', 'ci', 'db', 'coverage', 'codemod']);
|
|
144
145
|
const DATABASE_VERBS = new Map([['baseline', dbBaselineMain], ['restamp', dbRestampMain], ['migrate', dbMigrateMain], ['post-cutover', dbPostCutoverMain], ['dump', dbDumpMain], ['new', dbNewMain], ['drift', dbDriftMain]]);
|
|
145
146
|
/**
|
|
146
147
|
* The verbs parsed here, each with the only flags it takes ({ boolean, value, list }); a flag of another verb is refused, never ignored.
|
|
@@ -158,12 +159,13 @@ const PARSED = {
|
|
|
158
159
|
add: {
|
|
159
160
|
run: addMain, ...ADD_FLAGS, list: ['--inject'],
|
|
160
161
|
forms: [
|
|
161
|
-
{ operands: '<api|webhook|realtime|saga|job|reactor|queue|projection|cli|table|app|block|frame|composite|branch|leaf|page|overlay|layout> <name>', only: ADD_FLAGS },
|
|
162
|
+
{ operands: '<api|webhook|realtime|saga|job|reactor|queue|projection|cli|table|app|module|block|frame|composite|branch|leaf|page|overlay|layout> <name>', only: ADD_FLAGS },
|
|
162
163
|
{ operands: 'service <dir> <name>', only: { list: ['--inject'] } },
|
|
163
164
|
{ operands: 'spec <file>.service.ts', flags: [] },
|
|
164
165
|
{ operands: 'image', flags: [] },
|
|
165
166
|
],
|
|
166
167
|
},
|
|
168
|
+
remove: { run: removeMain, forms: [{ operands: 'module <name>', flags: [] }] },
|
|
167
169
|
check: { run: checkMain, value: ['--coverage-from', '--shard', '--side', '--out'], list: GATE_FLAGS, boolean: ['--json', '--staged', '--no-cache', '--fix'], edition: true, forms: [{ only: { boolean: ['--json', '--no-cache', '--fix'], list: GATE_FLAGS, value: ['--coverage-from', '--shard', '--side', '--out'] } }, { only: { boolean: ['--staged'] }, edition: false }] },
|
|
168
170
|
slot: { run: slotMain, boolean: ['--json'], forms: [{ operands: '<path>' }] },
|
|
169
171
|
emit: { run: emitMain, forms: [{}] },
|
|
@@ -204,6 +206,11 @@ const VERB_USAGE = {
|
|
|
204
206
|
world: ['starci app world <up|down|status> [--stack <dir>] [--services <list>] [--k3d] [--force] [--json] [--edition full|lite]'],
|
|
205
207
|
stack: [
|
|
206
208
|
...STACK_ACTIONS.map((action) => line(`starci app stack ${action}`, STACK_OPERANDS[action], STACK_READ_ONLY.includes(action) ? flagUsage('--env', { value: true }) : '--env <env>', STACK_FLAGS[action].map((flag) => flagUsage(flag, { value: STACK_VALUE.has(flag), repeat: STACK_REPEAT.has(flag) })), '[--json]')),
|
|
209
|
+
// The dev actions of a docker-compose environment (stack/local-cli.mts); prepare, status and verify above answer them too.
|
|
210
|
+
'starci app stack doctor --env <env> [--json]',
|
|
211
|
+
'starci app stack up --env <env> [--execute] [--timeout <seconds>] [--json]',
|
|
212
|
+
'starci app stack down --env <env> [--execute] [--json]',
|
|
213
|
+
'starci app stack logs --env <env> --service <name> [--lines <n>] [--json]',
|
|
207
214
|
],
|
|
208
215
|
release: Object.entries(RELEASE_FLAGS).map(([action, spec]) => line(`starci app release ${action}`, specUsage(spec))),
|
|
209
216
|
secret: SECRET_VERBS.map((verb) => line(`starci app secret ${verb}`, secretUsage(verb))),
|
|
@@ -680,6 +687,20 @@ function addMain(opts, { cwd: repoRoot, stdout: out }) {
|
|
|
680
687
|
out(`starci app add ${noun}: registered patterns ${registered.patterns.join(', ')}${kinds}\n`);
|
|
681
688
|
return 0;
|
|
682
689
|
}
|
|
690
|
+
/** `remove module <name>`: deletes the module package and its registrations and re-renders the registry. */
|
|
691
|
+
function removeMain(opts, { cwd: repoRoot, stdout: out }) {
|
|
692
|
+
const [noun, name, ...extra] = opts.positional;
|
|
693
|
+
if (noun !== 'module' || !name || extra.length)
|
|
694
|
+
throw new UsageError('starci app remove takes `module <name>`');
|
|
695
|
+
const { removed, updated, created } = removeModule({ root: repoRoot, name });
|
|
696
|
+
for (const file of removed)
|
|
697
|
+
out(`removed ${file}
|
|
698
|
+
`);
|
|
699
|
+
for (const file of [...updated, ...created])
|
|
700
|
+
out(`updated ${file}
|
|
701
|
+
`);
|
|
702
|
+
return 0;
|
|
703
|
+
}
|
|
683
704
|
async function scaffoldMain(opts, { cwd, harness, edition = 'full', presets, lock, stdout: out }) {
|
|
684
705
|
const [name, ...extra] = opts.positional;
|
|
685
706
|
const registryVisibility = valueFlag(opts, 'registry-visibility');
|
package/stack/cli.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { withDockerTransport } from './docker-transport.mjs';
|
|
|
18
18
|
import { StackError, EXIT, refusal } from './errors.mjs';
|
|
19
19
|
import { parseTargetBinding, bindStackTarget, nativeToolEnvironment } from './target-binding.mjs';
|
|
20
20
|
import { oneCommand, prepareUrgentRelease } from './urgent-release.mjs';
|
|
21
|
+
import { isLocalEnvironment, isLocalOnly, isLocalShared, runLocal } from './local-cli.mjs';
|
|
21
22
|
/** The actions that drive the docker CLI of the operator machine (pointed at the manager by docker-transport.mjs when the key is sealed). */
|
|
22
23
|
const DOCKER_ACTIONS = new Set(['deploy', 'accept', 'converge', 'verify', 'rollback', 'prune', 'status']);
|
|
23
24
|
export const ACTIONS = Object.freeze(['prepare', 'connect', 'disconnect', 'build-args', 'render', 'deploy', 'converge', 'verify', 'rollback', 'prune', 'backup', 'jobs', 'task', 'ingress', 'retire', 'build', 'accept', 'status', 'ci-key']);
|
|
@@ -185,8 +186,12 @@ export async function stackMain(argv, io = {}) {
|
|
|
185
186
|
const ctx = stackContext(io);
|
|
186
187
|
try {
|
|
187
188
|
const action = argv[0];
|
|
189
|
+
if (isLocalOnly(action))
|
|
190
|
+
return await runLocal(ctx, action, argv.slice(1), io.localDeps);
|
|
188
191
|
if (!isAction(action))
|
|
189
|
-
throw refusal('STACK_USAGE', `starci app stack takes ${[...ACTIONS, 'up', 'down', '
|
|
192
|
+
throw refusal('STACK_USAGE', `starci app stack takes ${[...ACTIONS, 'doctor', 'up', 'down', 'logs'].join(', ')}`);
|
|
193
|
+
if (isLocalShared(action) && argv.includes('--env') && isLocalEnvironment(ctx.cwd, String(argv[argv.indexOf('--env') + 1])))
|
|
194
|
+
return await runLocal(ctx, action, argv.slice(1), io.localDeps);
|
|
190
195
|
const { flags, positional } = parseFlags(action, argv.slice(1));
|
|
191
196
|
if (flags.env === undefined && !READ_ONLY.includes(action))
|
|
192
197
|
throw refusal('STACK_USAGE', `stack ${action} has an effect: name its environment with --env <env> (only ${READ_ONLY.join(', ')} default to production)`);
|
package/stack/context.d.mts
CHANGED
|
@@ -47,6 +47,16 @@ export interface PipeResult {
|
|
|
47
47
|
sink: number;
|
|
48
48
|
stderr: string;
|
|
49
49
|
}
|
|
50
|
+
/** One heavy command the dev verbs run (`npm ci`, a compose build or up): admitted by the harness's heavy-command governor when it hands one in. */
|
|
51
|
+
export interface HeavyRequest {
|
|
52
|
+
kind: 'install' | 'build';
|
|
53
|
+
label: string;
|
|
54
|
+
cwd: string;
|
|
55
|
+
argv: string[];
|
|
56
|
+
env: NodeJS.ProcessEnv;
|
|
57
|
+
}
|
|
58
|
+
/** The governed runner: waits for a slot of the host-wide semaphore, runs the argv without a shell and returns its result. */
|
|
59
|
+
export type HeavyRun = (request: HeavyRequest) => Promise<RunResult>;
|
|
50
60
|
/** The secret seam: decrypts the app's sops files. */
|
|
51
61
|
export interface Sops {
|
|
52
62
|
decrypt(file: string, format?: string): Record<string, string>;
|
|
@@ -82,6 +92,9 @@ export interface StackIo {
|
|
|
82
92
|
role?: string | undefined;
|
|
83
93
|
} | undefined;
|
|
84
94
|
importer?: ((file: string) => Promise<Record<string, unknown>>) | undefined;
|
|
95
|
+
heavy?: HeavyRun | undefined;
|
|
96
|
+
/** The seams of the dev verbs a spec replaces (stack/local-run.mjs LocalDeps). */
|
|
97
|
+
localDeps?: import('./local-run.mjs').LocalDeps | undefined;
|
|
85
98
|
}
|
|
86
99
|
/** The context of one stack invocation (see stackContext). */
|
|
87
100
|
export interface StackContext {
|
|
@@ -98,6 +111,8 @@ export interface StackContext {
|
|
|
98
111
|
readonly fetch: typeof fetch;
|
|
99
112
|
readonly pipe: (source: PipeEnd, sink: PipeEnd) => Promise<PipeResult>;
|
|
100
113
|
readonly sops: Sops;
|
|
114
|
+
/** The governed runner of heavy commands (the harness CLI hands it in); absent, a dev verb runs them directly. */
|
|
115
|
+
readonly heavy?: HeavyRun | undefined;
|
|
101
116
|
/** The command-owned SSH connection, set by the Docker transport (docker-transport.mjs). */
|
|
102
117
|
readonly stackSsh?: SshConnection | undefined;
|
|
103
118
|
}
|
package/stack/context.mjs
CHANGED
|
@@ -74,6 +74,7 @@ export function stackContext(io = {}) {
|
|
|
74
74
|
stderr: io.stderr ?? ((text) => process.stderr.write(text)),
|
|
75
75
|
fetch: io.fetch ?? globalThis.fetch,
|
|
76
76
|
pipe: io.pipe ?? realPipe,
|
|
77
|
+
heavy: io.heavy,
|
|
77
78
|
/** The secret seam: the app's own sops/age tools, created on first use so verbs that open no custody never need them. */
|
|
78
79
|
get sops() {
|
|
79
80
|
const opened = sops ?? io.openSops?.() ?? defaultSops(env, cwd, io.platform ?? process.platform, undefined, io.hostIdentity ?? null);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { isLocalEnvironment } from './local-config.mjs';
|
|
2
|
+
import type { StackContext } from './context.mjs';
|
|
3
|
+
import type { LocalDeps } from './local-run.mjs';
|
|
4
|
+
/** The actions that exist only for a dev environment. */
|
|
5
|
+
export declare const LOCAL_ONLY: readonly ["doctor", "up", "down", "logs"];
|
|
6
|
+
/** The actions a swarm environment already has, which a docker-compose environment answers with its dev implementation. */
|
|
7
|
+
export declare const LOCAL_SHARED: readonly ["prepare", "status", "verify"];
|
|
8
|
+
/** True when `value` is an action only a dev environment has. */
|
|
9
|
+
export declare const isLocalOnly: (value: unknown) => boolean;
|
|
10
|
+
/** True when `value` is an action both kinds have. */
|
|
11
|
+
export declare const isLocalShared: (value: unknown) => boolean;
|
|
12
|
+
/** The parsed flags of a dev action. */
|
|
13
|
+
export interface LocalFlags {
|
|
14
|
+
env?: string;
|
|
15
|
+
json: boolean;
|
|
16
|
+
execute: boolean;
|
|
17
|
+
timeout?: string;
|
|
18
|
+
service?: string;
|
|
19
|
+
lines?: string;
|
|
20
|
+
}
|
|
21
|
+
/** Parses the flags of one dev action; a flag of another action, an unknown flag and a missing value are refusals. */
|
|
22
|
+
export declare function parseLocalFlags(action: string, argv: readonly string[]): LocalFlags;
|
|
23
|
+
/** Runs one dev action and returns the exit code. */
|
|
24
|
+
export declare function runLocal(ctx: StackContext, action: string, argv: readonly string[], deps?: LocalDeps): Promise<number>;
|
|
25
|
+
export { isLocalEnvironment };
|