@starci/hfs 5.2.0 → 5.4.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 +43 -0
- package/README.md +2 -1
- package/cache/hfs-family.d.mts +2 -3
- package/cache/hfs-family.mjs +4 -9
- package/cache/secret-records.d.mts +7 -0
- package/cache/secret-records.mjs +12 -0
- package/cache/session.mjs +4 -2
- package/codemod/account-doors.d.mts +36 -0
- package/codemod/account-doors.mjs +134 -0
- package/codemod/app-prettier.d.mts +5 -0
- package/codemod/app-prettier.mjs +27 -0
- package/codemod/explicit-surface.mjs +1 -25
- package/codemod/main.mjs +35 -1
- package/codemod/module-definition.d.mts +59 -0
- package/codemod/module-definition.mjs +257 -0
- package/codemod/quality-binding.d.mts +33 -0
- package/codemod/quality-binding.mjs +38 -0
- package/codemod/secrets-cache.d.mts +35 -0
- package/codemod/secrets-cache.mjs +70 -0
- package/codemod/sops-rules.d.mts +34 -0
- package/codemod/sops-rules.mjs +69 -0
- package/codemod/theme-tokens.mjs +48 -2
- package/lint/run.d.mts +6 -0
- package/lint/run.mjs +63 -10
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +43 -10
- package/runtime/knowledge/hfs/fe-stack.yaml +3 -3
- package/runtime/knowledge/hfs/rules.yaml +13 -9
- package/runtime/knowledge/hfs/slots.yaml +105 -12
- package/runtime/modules/kernel/failure-codes.yaml +5 -4
- package/runtime/scripts/hfs/architecture/config.d.mts +4 -0
- package/runtime/scripts/hfs/architecture/config.mjs +25 -1
- package/runtime/scripts/hfs/architecture/module-per-transport.d.mts +1 -1
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +6 -1
- package/runtime/scripts/hfs/architecture/root-packages.d.mts +26 -0
- package/runtime/scripts/hfs/architecture/root-packages.mjs +78 -0
- package/runtime/scripts/hfs/architecture/type-context.d.mts +4 -1
- package/runtime/scripts/hfs/architecture/type-context.mjs +30 -0
- package/runtime/scripts/hfs/architecture/typescript.d.mts +7 -1
- package/runtime/scripts/hfs/architecture/typescript.mjs +21 -1
- package/runtime/scripts/hfs/explicit-surface-rule.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-access.mjs +39 -1
- package/runtime/scripts/hfs/rules/monorepo.d.mts +4 -0
- package/runtime/scripts/hfs/rules/monorepo.mjs +10 -5
- package/runtime/scripts/hfs/rules/secrets.mjs +11 -0
- package/runtime/scripts/hfs/secret-errors.d.mts +3 -0
- package/runtime/scripts/hfs/secret-errors.mjs +8 -0
- package/runtime/scripts/hfs/secret-init.d.mts +6 -0
- package/runtime/scripts/hfs/secret-init.mjs +23 -0
- package/runtime/scripts/hfs/secret-sops.mjs +9 -1
- package/runtime/scripts/hfs/secret-standard.d.mts +20 -0
- package/runtime/scripts/hfs/secret-standard.mjs +106 -0
- package/runtime/scripts/hfs/secret.mjs +5 -1
- package/runtime/scripts/hfs/view.d.mts +23 -0
- package/runtime/scripts/hfs/view.mjs +68 -0
- package/runtime/scripts/lib/declaration-surface.d.mts +4 -0
- package/runtime/scripts/lib/declaration-surface.mjs +55 -1
- package/runtime/scripts/lib/secret-records.d.mts +13 -0
- package/runtime/scripts/lib/secret-records.mjs +83 -0
- package/scaffold/add-inbox-lite.mjs +2 -2
- package/scaffold/add-queue-lite.mjs +1 -3
- package/scaffold/app.d.mts +2 -2
- package/scaffold/app.mjs +10 -12
- package/scaffold/lite-exports.mjs +1 -15
- package/scripts/sync-bundles.d.mts +8 -0
- package/stack/images/openclaw-runtime/Dockerfile +18 -0
- package/stack/images/openclaw-runtime/entrypoint.sh +23 -0
- package/stack/images/openclaw-runtime/openclaw-init.mjs +101 -0
- package/stack/images/openclaw-runtime/openclaw-relay.mjs +25 -0
- package/stack/local-up.mjs +3 -0
- package/stack/runtime-layers.d.mts +21 -0
- package/stack/runtime-layers.mjs +67 -0
- package/sync/compile-guard.d.mts +36 -0
- package/sync/compile-guard.mjs +189 -0
- package/sync/hygiene.mjs +3 -1
- package/sync/index.d.mts +11 -1
- package/sync/index.mjs +61 -10
- package/templates/app/auth-doors.sql.partial +115 -0
- package/templates/app/ci-workflows/github/workflows/ci.yml +1 -1
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -1
- package/templates/app/package-scripts/core.json +1 -1
- 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 +9 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +3 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/compose/compose.yaml +19 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +3 -0
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +5 -0
- package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +4 -0
- package/templates/app/skeleton-lite/db/migrations/__timestamp___baseline.sql +27 -5
- package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +33 -0
- package/templates/be/auth-lite/src/features/api/auth/application/confirm.command.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/confirm.handler.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/application/forgot.command.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/forgot.handler.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/application/resend.command.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/resend.handler.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/application/reset.command.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/reset.handler.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/application/signup.command.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/signup.handler.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/auth.module-definition.ts +3 -10
- package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +6 -1
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.module-definition.ts +3 -10
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +56 -1
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/forgot.request.ts +16 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/resend.request.ts +16 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/reset.request.ts +18 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/signup.request.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/token.request.ts +11 -0
- package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +30 -30
- package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +7 -16
- package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +113 -49
- package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +42 -53
- package/templates/be/auth-lite/src/modules/domain/identity/identity-email.log-events.ts +6 -0
- package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.spec.ts +233 -0
- package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.ts +176 -0
- package/templates/be/auth-lite/src/modules/domain/identity/identity-sign-in.log-events.ts +6 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/account-doors.port.ts +98 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-email-token.entity.ts +34 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-refresh-token.entity.ts +4 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +3 -10
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +6 -3
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +6 -1
- package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +17 -0
- package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +6 -0
- package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +29 -33
- package/templates/be/inbox/module-definition.ts.tpl +2 -9
- package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +2 -11
- package/templates/be/inbox/platform/inbox/inbox.module-definition.ts.tpl +2 -9
- package/templates/be/patterns/api/feature.module-definition.ts.tpl +3 -9
- package/templates/be/patterns/api/graphql.module-definition.ts.tpl +3 -9
- package/templates/be/patterns/cli/group.module-definition.ts.tpl +2 -9
- package/templates/be/patterns/event-bus/platform/event-bus.module-definition.ts.tpl +3 -10
- package/templates/be/patterns/idempotency/platform/inbox/inbox.module-definition.ts.tpl +2 -9
- package/templates/be/patterns/jobs/job.module-definition.ts.tpl +2 -9
- package/templates/be/patterns/jobs/job.queue.module-definition.ts.tpl +3 -9
- package/templates/be/patterns/jobs/platform/jobs.module-definition.ts.tpl +3 -10
- package/templates/be/patterns/projections/projection.module-definition.ts.tpl +2 -9
- package/templates/be/patterns/queues/platform/queue.module-definition.ts.tpl +3 -10
- package/templates/be/patterns/queues/queue.module-definition.ts.tpl +2 -9
- package/templates/be/patterns/reactors/message.module-definition.ts.tpl +3 -9
- package/templates/be/patterns/reactors/reactor.module-definition.ts.tpl +2 -9
- package/templates/be/patterns/realtime/channel.graphql.module-definition.ts.tpl +3 -9
- package/templates/be/patterns/realtime/channel.sse.module-definition.ts.tpl +3 -9
- package/templates/be/patterns/realtime/channel.websocket.module-definition.ts.tpl +3 -9
- package/templates/be/patterns/saga/saga.message.module-definition.ts.tpl +3 -9
- package/templates/be/patterns/saga/saga.module-definition.ts.tpl +2 -9
- package/templates/be/patterns/webhooks/webhook.http.module-definition.ts.tpl +3 -9
- package/templates/be/platform/graphql/graphql.module-definition.ts +2 -9
- package/templates/be/skeleton/src/features/api/system-health/system-health.module-definition.ts +3 -9
- package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module-definition.ts +3 -9
- package/templates/be/skeleton/src/features/cli/cli.module-definition.ts +2 -9
- package/templates/be/skeleton/src/features/cli/migrate/migrate.module-definition.ts +3 -9
- package/templates/be/skeleton/src/features/cli/seed/seed.module-definition.ts +2 -9
- package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +2 -9
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.module-definition.ts +2 -9
- package/templates/be/skeleton/src/modules/domain/note/note.module-definition.ts +2 -9
- package/templates/be/skeleton/src/modules/platform/clock/clock.module-definition.ts +2 -9
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.contracts.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module-definition.ts +2 -9
- package/templates/be/skeleton/src/modules/platform/database/database.module-definition.ts +3 -13
- package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +4 -11
- package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +4 -9
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +5 -11
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +4 -11
- package/templates/be/skeleton/src/modules/platform/ids/ids.module-definition.ts +2 -9
- package/templates/be/skeleton/src/modules/platform/logging/logging.module-definition.ts +2 -9
- package/templates/be/skeleton/src/modules/platform/retry/retry.module-definition.ts +2 -9
- package/templates/be/skeleton-lite/apps/api/src/api.env.ts +4 -0
- package/templates/be/skeleton-lite/apps/api/src/api.options.ts +3 -0
- package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +3 -1
- package/templates/be/skeleton-lite/src/modules/domain/identity/errors/identity.error.ts +6 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +3 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/index.ts +1 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/messages/identity-mail.messages.ts +65 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/messages/identity.messages.ts +4 -0
- package/templates/be/skeleton-lite/src/modules/integrations/mail/index.ts +6 -0
- package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.config.ts +33 -0
- package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.decorators.ts +14 -0
- package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.module-definition.ts +12 -0
- package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.module.ts +27 -0
- package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.options.ts +22 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.module-definition.ts +2 -9
- package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +11 -0
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +11 -0
- package/templates/be/table/api/feature.module-definition.ts.tpl +3 -9
- package/templates/be/table/api/graphql.module-definition.ts.tpl +3 -9
- package/templates/be/table/module-definition.ts.tpl +2 -9
- package/templates/fe/auth-lite/apps/web/src/modules/auth/email-doors.ts +16 -0
- package/templates/fe/auth-lite/apps/web/src/modules/auth/index.ts +5 -0
- package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +30 -67
- package/templates/fe/skeleton/packages/__project__-i18n/src/proxy.ts +6 -21
- package/templates/fe/skeleton/packages/__project__-i18n/src/routing.ts +10 -16
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/confirm/page.tsx +9 -0
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/forgot/page.tsx +9 -0
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/resend/page.tsx +9 -0
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/reset/page.tsx +9 -0
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/signup/page.tsx +9 -0
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +5 -9
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/component.tsx +51 -0
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/index.tsx +84 -0
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppConfirmPage/index.tsx +25 -0
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppForgotPage/index.tsx +21 -0
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/component.tsx +11 -3
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +4 -1
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppResendPage/index.tsx +21 -0
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppResetPage/index.tsx +25 -0
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppSignupPage/index.tsx +21 -0
- package/templates/fe/skeleton-lite/apps/web/src/hooks/emailAuth/emailAuth.shared.ts +12 -0
- package/templates/fe/skeleton-lite/apps/web/src/hooks/emailAuth/index.ts +2 -0
- package/templates/fe/skeleton-lite/apps/web/src/hooks/emailAuth/useEmailDoor.ts +32 -0
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/index.ts +2 -2
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +50 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +50 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +7 -30
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/routing.ts +13 -14
- package/templates/fe/skeleton-lite/apps/web/src/modules/routes/index.ts +5 -0
- package/templates/fe/skeleton-lite/apps/web/src/proxy.ts +4 -17
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-identity.entity.ts +0 -38
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-user.entity.ts +0 -54
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// compile-guard.mjs - HFS_SYNC_GROUP_HELD: `starci app sync --execute` never writes a render that leaves the app uncompilable. Before it writes
|
|
2
|
+
// a managed TypeScript source (or deletes a retired one), the side's own TypeScript compiles the app with the writes laid over the disk and
|
|
3
|
+
// compares the diagnostics of every file they touch, and of every file that imports one, with the same files on disk. A managed group whose
|
|
4
|
+
// writes add an error (a template that reads a name the app does not provide yet, or drops one the app still imports) is held whole: none
|
|
5
|
+
// of its files is written, the rest of the render is, and the sync names the group, its files and the errors, so the app provides what the
|
|
6
|
+
// render reads and runs sync again. A side whose TypeScript is not installed cannot be compiled; its writes go through and say so.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { loadTypescript } from '../runtime/scripts/lib/package-at.mjs';
|
|
10
|
+
/** The refusal code of a held group. */
|
|
11
|
+
export const GROUP_HELD = 'HFS_SYNC_GROUP_HELD';
|
|
12
|
+
/** The pseudo-group of the retired files a sync deletes. */
|
|
13
|
+
export const RETIRED_GROUP = 'retired';
|
|
14
|
+
/** How many errors a held group prints. */
|
|
15
|
+
const SHOWN_ERRORS = 20;
|
|
16
|
+
const SOURCE = /\.tsx?$/;
|
|
17
|
+
/** Whether `file` (app-relative) is TypeScript source a side compiles. */
|
|
18
|
+
export const isGuardedSource = (file) => SOURCE.test(file) && !file.endsWith('.d.ts') && (file.startsWith('be/') || file.startsWith('fe/'));
|
|
19
|
+
/** The tsconfig that compiles an app-relative source: `be/tsconfig.json`, or the front-end app's or package's own; null when there is none. */
|
|
20
|
+
export function projectOf(root, file) {
|
|
21
|
+
const parts = file.split('/');
|
|
22
|
+
const project = parts[0] === 'be' ? 'be/tsconfig.json' : parts.length > 3 ? `fe/${parts[1]}/${parts[2]}/tsconfig.json` : null;
|
|
23
|
+
return project !== null && fs.existsSync(path.join(root, project)) ? project : null;
|
|
24
|
+
}
|
|
25
|
+
/** One side's compilation: its compiler, options and root files, the overlay of writes keyed the way the host compares file names. */
|
|
26
|
+
class Side {
|
|
27
|
+
#ts;
|
|
28
|
+
#root;
|
|
29
|
+
#options;
|
|
30
|
+
#rootNames;
|
|
31
|
+
#baseline = new Map();
|
|
32
|
+
#program;
|
|
33
|
+
constructor(ts, root, project) {
|
|
34
|
+
this.#ts = ts;
|
|
35
|
+
this.#root = root;
|
|
36
|
+
const file = path.join(root, project);
|
|
37
|
+
const read = ts.readConfigFile(file, ts.sys.readFile);
|
|
38
|
+
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(file), { noEmit: true }, file);
|
|
39
|
+
this.#options = { ...parsed.options, noEmit: true };
|
|
40
|
+
this.#rootNames = parsed.fileNames;
|
|
41
|
+
}
|
|
42
|
+
/** The name the host compares: absolute, forward slashes, lower case where the file system ignores case. */
|
|
43
|
+
key(file) {
|
|
44
|
+
const absolute = path.resolve(this.#root, file).replaceAll('\\', '/');
|
|
45
|
+
return this.#ts.sys.useCaseSensitiveFileNames ? absolute : absolute.toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
#host(overlay) {
|
|
48
|
+
const host = this.#ts.createCompilerHost(this.#options, true);
|
|
49
|
+
const { readFile, fileExists, getSourceFile } = host;
|
|
50
|
+
host.readFile = (name) => (overlay.has(this.key(name)) ? overlay.get(this.key(name)) ?? undefined : readFile.call(host, name));
|
|
51
|
+
host.fileExists = (name) => (overlay.has(this.key(name)) ? overlay.get(this.key(name)) !== null : fileExists.call(host, name));
|
|
52
|
+
host.getSourceFile = (name, language, onError, create) => {
|
|
53
|
+
if (!overlay.has(this.key(name)))
|
|
54
|
+
return getSourceFile.call(host, name, language, onError, create);
|
|
55
|
+
const text = overlay.get(this.key(name));
|
|
56
|
+
return text === null || text === undefined ? undefined : this.#ts.createSourceFile(name, text, language, true);
|
|
57
|
+
};
|
|
58
|
+
return host;
|
|
59
|
+
}
|
|
60
|
+
#programOf(overlay, added) {
|
|
61
|
+
const rootNames = [...this.#rootNames.filter((name) => overlay.get(this.key(name)) !== null), ...added];
|
|
62
|
+
return this.#ts.createProgram({ rootNames, options: this.#options, host: this.#host(overlay), ...(this.#program === undefined ? {} : { oldProgram: this.#program }) });
|
|
63
|
+
}
|
|
64
|
+
/** The files of the program that import one of `targets` (keys), each with the targets it imports. */
|
|
65
|
+
importers(targets, overlay) {
|
|
66
|
+
const host = this.#host(overlay);
|
|
67
|
+
const cache = this.#ts.createModuleResolutionCache(this.#root, (name) => this.key(name), this.#options);
|
|
68
|
+
const found = new Map();
|
|
69
|
+
for (const name of this.#rootNames) {
|
|
70
|
+
const text = host.readFile(name);
|
|
71
|
+
if (text === undefined)
|
|
72
|
+
continue;
|
|
73
|
+
for (const imported of this.#ts.preProcessFile(text, true, true).importedFiles) {
|
|
74
|
+
const resolved = this.#ts.resolveModuleName(imported.fileName, name, this.#options, host, cache).resolvedModule?.resolvedFileName;
|
|
75
|
+
if (resolved === undefined || !targets.has(this.key(resolved)))
|
|
76
|
+
continue;
|
|
77
|
+
const into = found.get(this.key(name)) ?? new Set();
|
|
78
|
+
into.add(this.key(resolved));
|
|
79
|
+
found.set(this.key(name), into);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return found;
|
|
83
|
+
}
|
|
84
|
+
/** The errors of `files` (keys) as `path TScode message`, each file once, in the program with `overlay` laid over the disk. */
|
|
85
|
+
errors(files, overlay, added) {
|
|
86
|
+
const program = this.#programOf(overlay, added);
|
|
87
|
+
if (overlay.size === 0)
|
|
88
|
+
this.#program = program;
|
|
89
|
+
const byKey = new Map(program.getSourceFiles().map((source) => [this.key(source.fileName), source]));
|
|
90
|
+
const answer = new Map();
|
|
91
|
+
for (const file of files) {
|
|
92
|
+
const source = byKey.get(file);
|
|
93
|
+
const relative = path.relative(this.#root, source?.fileName ?? file).replaceAll('\\', '/');
|
|
94
|
+
const found = source === undefined ? [] : [...program.getSyntacticDiagnostics(source), ...program.getSemanticDiagnostics(source)];
|
|
95
|
+
answer.set(file, found.map((diagnostic) => `${relative}${source === undefined || diagnostic.start === undefined ? '' : `:${String(source.getLineAndCharacterOfPosition(diagnostic.start).line + 1)}`} TS${String(diagnostic.code)} ${this.#ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ')}`));
|
|
96
|
+
}
|
|
97
|
+
return answer;
|
|
98
|
+
}
|
|
99
|
+
/** The errors of `files` on disk, computed once per file. */
|
|
100
|
+
baseline(files) {
|
|
101
|
+
const missing = files.filter((file) => !this.#baseline.has(file));
|
|
102
|
+
if (missing.length > 0)
|
|
103
|
+
for (const [file, found] of this.errors(missing, new Map(), []))
|
|
104
|
+
this.#baseline.set(file, found);
|
|
105
|
+
return new Map(files.map((file) => [file, this.#baseline.get(file) ?? []]));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** An error without its line, so a line moved by the write is the same error. */
|
|
109
|
+
const unplaced = (error) => error.replace(/^([^ ]*?):\d+ /, '$1 ');
|
|
110
|
+
/** The errors of `after` that `before` does not hold, counted as a multiset of unplaced errors. */
|
|
111
|
+
function added(before, after) {
|
|
112
|
+
const left = new Map();
|
|
113
|
+
for (const error of before)
|
|
114
|
+
left.set(unplaced(error), (left.get(unplaced(error)) ?? 0) + 1);
|
|
115
|
+
return after.filter((error) => {
|
|
116
|
+
const count = left.get(unplaced(error)) ?? 0;
|
|
117
|
+
left.set(unplaced(error), count - 1);
|
|
118
|
+
return count <= 0;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/** The held groups of one side: compile with every write of the groups still in play, hold the groups that add an error, again until none does. */
|
|
122
|
+
function judgeSide(side, writes) {
|
|
123
|
+
const held = [];
|
|
124
|
+
let open = [...writes];
|
|
125
|
+
while (open.length > 0) {
|
|
126
|
+
const overlay = new Map(open.map((write) => [side.key(write.path), write.text]));
|
|
127
|
+
const groupOf = new Map(open.map((write) => [side.key(write.path), write.group]));
|
|
128
|
+
// An importer is found on the disk with the new files added, so the importer of a file the writes delete is found too.
|
|
129
|
+
const importers = side.importers(new Set(overlay.keys()), new Map([...overlay].filter(([, text]) => text !== null)));
|
|
130
|
+
const files = [...new Set([...[...overlay.keys()].filter((file) => overlay.get(file) !== null), ...importers.keys()])];
|
|
131
|
+
const before = side.baseline(files);
|
|
132
|
+
const after = side.errors(files, overlay, open.filter((write) => write.text !== null).map((write) => path.resolve(write.path)));
|
|
133
|
+
const errors = new Map();
|
|
134
|
+
for (const file of files) {
|
|
135
|
+
const fresh = added(before.get(file) ?? [], after.get(file) ?? []);
|
|
136
|
+
if (fresh.length === 0)
|
|
137
|
+
continue;
|
|
138
|
+
const groups = groupOf.has(file) ? [groupOf.get(file)] : [...(importers.get(file) ?? [])].map((target) => groupOf.get(target));
|
|
139
|
+
for (const group of groups)
|
|
140
|
+
errors.set(group, [...(errors.get(group) ?? []), ...fresh]);
|
|
141
|
+
}
|
|
142
|
+
if (errors.size === 0)
|
|
143
|
+
break;
|
|
144
|
+
for (const [group, found] of errors)
|
|
145
|
+
held.push({ group, files: open.filter((write) => write.group === group).map((write) => write.path), errors: [...new Set(found)] });
|
|
146
|
+
open = open.filter((write) => !errors.has(write.group));
|
|
147
|
+
}
|
|
148
|
+
return held;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The groups `starci app sync --execute` holds back at `root`: every write of a TypeScript source is compiled, per side, with the side's own
|
|
152
|
+
* TypeScript (`loadTs`, the app's install by default); a side with no tsconfig compiles nothing, a side whose TypeScript is not installed is
|
|
153
|
+
* named in `unchecked`.
|
|
154
|
+
*/
|
|
155
|
+
export function guardWrites(root, writes, loadTs = (dir) => loadTypescript(dir, root)) {
|
|
156
|
+
const verdict = { held: [], unchecked: [] };
|
|
157
|
+
const bySide = new Map();
|
|
158
|
+
for (const write of writes.filter((candidate) => isGuardedSource(candidate.path))) {
|
|
159
|
+
const project = projectOf(root, write.path);
|
|
160
|
+
if (project !== null)
|
|
161
|
+
bySide.set(project, [...(bySide.get(project) ?? []), { ...write, path: path.join(root, write.path) }]);
|
|
162
|
+
}
|
|
163
|
+
for (const [project, sideWrites] of bySide) {
|
|
164
|
+
const ts = loadTs(path.join(root, path.dirname(project)));
|
|
165
|
+
if (ts === null) {
|
|
166
|
+
verdict.unchecked.push(project);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
for (const group of judgeSide(new Side(ts, root, project), sideWrites)) {
|
|
170
|
+
const relative = group.files.map((file) => path.relative(root, file).replaceAll('\\', '/'));
|
|
171
|
+
const known = verdict.held.find((entry) => entry.group === group.group);
|
|
172
|
+
if (known === undefined)
|
|
173
|
+
verdict.held.push({ ...group, files: relative });
|
|
174
|
+
else
|
|
175
|
+
verdict.held.splice(verdict.held.indexOf(known), 1, { group: group.group, files: [...known.files, ...relative], errors: [...known.errors, ...group.errors] });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return verdict;
|
|
179
|
+
}
|
|
180
|
+
/** The lines sync prints for a held group: what is held and why, the first errors, and what to do. */
|
|
181
|
+
export function heldLines(group) {
|
|
182
|
+
const more = group.errors.length > SHOWN_ERRORS ? [` ... ${String(group.errors.length - SHOWN_ERRORS)} more`] : [];
|
|
183
|
+
return [
|
|
184
|
+
`${GROUP_HELD} ${group.group}: ${String(group.files.length)} files not written, the render adds ${String(group.errors.length)} type errors to the app (${group.files.join(', ')})`,
|
|
185
|
+
...group.errors.slice(0, SHOWN_ERRORS).map((error) => ` ${error}`),
|
|
186
|
+
...more,
|
|
187
|
+
` make the app provide what the render reads (each error names it), then run "npx --no-install starci app sync --execute" again`,
|
|
188
|
+
];
|
|
189
|
+
}
|
package/sync/hygiene.mjs
CHANGED
|
@@ -45,6 +45,8 @@ const PLAINTEXT_NAME = /(^|\/)(\.env(\..*)?|[^/]*\.(pem|key|identity|age))$/;
|
|
|
45
45
|
/** The app root's work tree and stack tree, app-relative (`starci app check --staged` runs at the app root). */
|
|
46
46
|
const WORK = '.starciwork/';
|
|
47
47
|
const STACKS = '.starcistacks/';
|
|
48
|
+
/** The committed check cache: written by the CLI, judged by what its files hold (secret-records.mjs through the secret judgement), never by a `secrets/` folder in its path. */
|
|
49
|
+
const CACHE = '.starcistacks/cache/';
|
|
48
50
|
const GUARDED = (file) => file.startsWith(WORK) || file.startsWith(STACKS);
|
|
49
51
|
/** The subset of `files` git ignores (as if untracked), asked in one call: a Set of paths. */
|
|
50
52
|
function ignoredAmong(cwd, files) {
|
|
@@ -67,7 +69,7 @@ export function hygieneFindings(files, ignored) {
|
|
|
67
69
|
if (file.startsWith(WORK) && ignored.has(file)) {
|
|
68
70
|
findings.push({ file, code: 'HFS_WORK_AGENT_DATA', message: 'is agent output, which .starciwork/.gitignore refuses; keep it in the scratchpad or the blob store' });
|
|
69
71
|
}
|
|
70
|
-
if (file.startsWith(STACKS) && !/\.enc(?:\.yaml)?$/.test(file) && !file.endsWith('.env.example') && !file.endsWith('/secrets/.gitkeep')) {
|
|
72
|
+
if (file.startsWith(STACKS) && !file.startsWith(CACHE) && !/\.enc(?:\.yaml)?$/.test(file) && !file.endsWith('.env.example') && !file.endsWith('/secrets/.gitkeep')) {
|
|
71
73
|
if (file.includes('/secrets/'))
|
|
72
74
|
findings.push({ file, code: 'HFS_PLAINTEXT_SECRET', message: 'sits under secrets/ but is not sealed; only <slug>.enc may be tracked' });
|
|
73
75
|
else if (PLAINTEXT_NAME.test(file))
|
package/sync/index.d.mts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type RepoView } from '../runtime/scripts/hfs/slots.mjs';
|
|
2
2
|
import type { AppRepo } from '../runtime/scripts/hfs/slot-app-view.mjs';
|
|
3
3
|
import type { Slot, SlotManifest } from '../runtime/scripts/hfs/manifest-shape.mjs';
|
|
4
|
+
import { guardWrites, type GuardedWrite } from './compile-guard.mjs';
|
|
4
5
|
import type { ImageApp } from './stack-images.mjs';
|
|
5
6
|
export declare const TEMPLATES_DIR: string;
|
|
6
7
|
/** The scopes a target is rendered for: the app root, then each side. */
|
|
@@ -22,6 +23,7 @@ export interface Target {
|
|
|
22
23
|
template: string;
|
|
23
24
|
mode: string;
|
|
24
25
|
slot?: string;
|
|
26
|
+
group?: string;
|
|
25
27
|
markdown?: boolean;
|
|
26
28
|
}
|
|
27
29
|
/** The markers around a managed block. */
|
|
@@ -39,6 +41,8 @@ export interface RenderedTarget {
|
|
|
39
41
|
scripts?: Record<string, unknown>;
|
|
40
42
|
markdown?: boolean | undefined;
|
|
41
43
|
markers?: Markers;
|
|
44
|
+
/** The managed group of a slot's file (`managedBy`), which sync writes or holds whole (compile-guard.mjs). */
|
|
45
|
+
group?: string;
|
|
42
46
|
}
|
|
43
47
|
/** What comparing a rendered target with the disk answers. */
|
|
44
48
|
export interface TargetCheck {
|
|
@@ -140,6 +144,8 @@ export declare function sonarScope(presets: Presets | null | undefined): {
|
|
|
140
144
|
tests: string[];
|
|
141
145
|
exclusions: string[];
|
|
142
146
|
};
|
|
147
|
+
/** The managed ESLint config at the app root that lints the root workspace packages. */
|
|
148
|
+
export declare const PACKAGES_LINT_FILE = "eslint.config.mjs";
|
|
143
149
|
export declare function appSource(root: string, { parseYaml, notices }?: {
|
|
144
150
|
parseYaml?: ParseYaml | undefined;
|
|
145
151
|
notices?: string[] | undefined;
|
|
@@ -227,9 +233,13 @@ export declare function renderRepo(root: string, { presets, parseYaml, notices,
|
|
|
227
233
|
export declare function retiredFiles(root: string, hfs: {
|
|
228
234
|
edition?: string;
|
|
229
235
|
} | null | undefined): string[];
|
|
230
|
-
|
|
236
|
+
/** The writes of `targets` and the deletions of `retired` the compile guard judges: every file sync would change, with its managed group. */
|
|
237
|
+
export declare function guardedWrites(root: string, targets: readonly RenderedTarget[], retired: readonly string[]): GuardedWrite[];
|
|
238
|
+
export declare function runSync(argv: string[], { cwd, out, presets, parseYaml, loadTs }?: {
|
|
231
239
|
cwd?: string;
|
|
232
240
|
out?: (line: string) => unknown;
|
|
233
241
|
presets?: Presets | undefined;
|
|
234
242
|
parseYaml?: ParseYaml | undefined;
|
|
243
|
+
/** The TypeScript of a side folder the compile guard compiles with; the app's own install by default. */
|
|
244
|
+
loadTs?: Parameters<typeof guardWrites>[2];
|
|
235
245
|
}): Promise<number>;
|
package/sync/index.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import { feAppPackageName } from '../runtime/scripts/hfs/rules/monorepo.mjs';
|
|
|
29
29
|
import { DEFAULT_PORT, NODE_IMAGE, dockerfilePath } from '../runtime/scripts/hfs/rules/docker.mjs';
|
|
30
30
|
import { isFile } from '../runtime/scripts/lib/fs-kind.mjs';
|
|
31
31
|
import { readDeclaredSonarKey } from './sonar-key.mjs';
|
|
32
|
+
import { RETIRED_GROUP, guardWrites, heldLines, isGuardedSource } from './compile-guard.mjs';
|
|
32
33
|
import { readDeclaredStack, stackWorkflowVariables } from './stack-images.mjs';
|
|
33
34
|
import { DB_DOCKERFILE, DB_IMAGE_APP, RETIRED_DB_DOCKERFILE } from '../db/image.mjs';
|
|
34
35
|
import { byCodeUnit } from '../report/order.mjs';
|
|
@@ -99,7 +100,7 @@ function slotTargets(slot, scope, edition, manifest, hasTemplate, enabled) {
|
|
|
99
100
|
const template = managedTemplate(scope, group, file, hasTemplate);
|
|
100
101
|
if (!template)
|
|
101
102
|
throw new SyncError('HFS_SYNC_TEMPLATE_MISSING', `slot ${slot.id} manages ${file} for ${scope}, but templates/{${scope},common}/${group} has no template for it`);
|
|
102
|
-
return { path: file, template, mode: MODE_OF_GROUP[group] ?? 'file', slot: slot.id };
|
|
103
|
+
return { path: file, template, mode: MODE_OF_GROUP[group] ?? 'file', slot: slot.id, group };
|
|
103
104
|
});
|
|
104
105
|
}
|
|
105
106
|
function unlistedTargets(scope, edition) {
|
|
@@ -237,6 +238,9 @@ export function sonarScope(presets) {
|
|
|
237
238
|
const tests = preset.filter(glob => !glob.endsWith('/**'));
|
|
238
239
|
return { tests, exclusions: [...new Set([...preset, '**/.next/**', '**/node_modules/**'])] };
|
|
239
240
|
}
|
|
241
|
+
/** The managed ESLint config at the app root that lints the root workspace packages. */
|
|
242
|
+
export const PACKAGES_LINT_FILE = 'eslint.config.mjs';
|
|
243
|
+
const LINE = '\n';
|
|
240
244
|
const SOURCE_FILE = /\.[cm]?ts$/;
|
|
241
245
|
const SOURCE_SKIPPED = new Set(['node_modules', 'dist', 'coverage']);
|
|
242
246
|
/**
|
|
@@ -396,6 +400,30 @@ const moduleExclusions = (dirs) => dirs.flatMap(dir => [`${dir}/src/workbench/**
|
|
|
396
400
|
function registryTargets(app, source) {
|
|
397
401
|
return registryFiles(app.project, source?.modules ?? []).map((file) => ({ path: file.path, scope: APP_SCOPE, mode: 'file', content: file.content, hash: hashOf(file.content) }));
|
|
398
402
|
}
|
|
403
|
+
/**
|
|
404
|
+
* The root `eslint.config.mjs` of an app that has root workspace packages (packages/agent-sdk, packages/module-<name>, the registry): no side config
|
|
405
|
+
* reaches `packages/`, so this managed file lints their source by subpath. The back-end canon judges everything but the front-end subpaths
|
|
406
|
+
* (`src/workbench/` outside its contract, `src/public/`), the front-end canon and the selected Grammar family those, each over the package view of
|
|
407
|
+
* the app root. An app without a root package has no such file; the family factory for packages is the family's `<name>GrammarPackagesConfig`.
|
|
408
|
+
*/
|
|
409
|
+
function packagesLintTargets(app, source) {
|
|
410
|
+
if (!(source?.modules?.length || source?.otherPackages?.length))
|
|
411
|
+
return [];
|
|
412
|
+
const grammar = grammarLintFor(app.sides.fe.grammar);
|
|
413
|
+
const factory = grammar.package ? grammar.factory.replace(/Config$/, 'PackagesConfig') : null;
|
|
414
|
+
const content = [
|
|
415
|
+
'import { loadPackagesHfs as loadBePackages, starciBePackagesConfig } from "@starci/eslint-canon-be"',
|
|
416
|
+
'import { loadPackagesHfs as loadFePackages, starciFePackagesConfig } from "@starci/eslint-canon-fe"',
|
|
417
|
+
...(grammar.package ? [`import { ${factory} } from "${grammar.package}"`] : []),
|
|
418
|
+
'',
|
|
419
|
+
'const be = loadBePackages(import.meta.url)',
|
|
420
|
+
'const fe = loadFePackages(import.meta.url)',
|
|
421
|
+
'',
|
|
422
|
+
`export default [...(await starciBePackagesConfig({ hfs: be })), ...starciFePackagesConfig({ hfs: fe })${grammar.package ? `, ...${factory}({ hfs: fe })` : ''}]`,
|
|
423
|
+
'',
|
|
424
|
+
].join(LINE);
|
|
425
|
+
return [{ path: PACKAGES_LINT_FILE, scope: APP_SCOPE, mode: 'file', content, hash: hashOf(content) }];
|
|
426
|
+
}
|
|
399
427
|
export function variables(app, scope, presets, sonarKey, manifest = loadSlotManifest(), source = { files: [], read: () => '' }) {
|
|
400
428
|
const stack = source.stack ?? { images: [], passEnv: [] };
|
|
401
429
|
const fe = app.sides.fe;
|
|
@@ -429,7 +457,9 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
|
|
|
429
457
|
typecheck: `${modulesFirst}npm run codegen --silent && tsc -p be/tsconfig.json && turbo run typecheck`,
|
|
430
458
|
// The typed lint reads the types the fe packages declare (their dist), so the packages are built first.
|
|
431
459
|
lintPrefix: `${modulesFirst}${packages ? 'npm run codegen --silent && turbo run build --filter=./fe/packages/* && ' : 'npm run codegen --silent && '}`,
|
|
432
|
-
|
|
460
|
+
// The unit run, the back-end build and every CI job that imports a module package find it built (build:modules precedes them); the logic of a package is held at 100 per file by its own jest run, a step of the fast job.
|
|
461
|
+
modulesFirst,
|
|
462
|
+
moduleTestStep: modules.length ? ' - name: module packages\n run: npm run test:modules\n' : '',
|
|
433
463
|
nodeMajor: String(NODE_MAJOR),
|
|
434
464
|
// The deploy server's age identity the restore workflow writes (stack/server-key.mjs owns the path).
|
|
435
465
|
serverAgeIdentity: SERVER_AGE_IDENTITY,
|
|
@@ -503,7 +533,7 @@ function renderOnce(hfs, presets, { sonarKey, readTemplate = readBundled, manife
|
|
|
503
533
|
declaredTargets.push({ path: 'README.md', mode: 'block', markdown: true, template: 'app/readme-badges/README.md' });
|
|
504
534
|
const targets = declaredTargets
|
|
505
535
|
.filter(target => !(app.edition === 'lite' && source?.workPresent === false && target.path === '.starciwork/.gitignore'));
|
|
506
|
-
return [...(scope === APP_SCOPE ? registryTargets(app, source) : []), ...targets.map((target) => {
|
|
536
|
+
return [...(scope === APP_SCOPE ? [...registryTargets(app, source), ...packagesLintTargets(app, source)] : []), ...targets.map((target) => {
|
|
507
537
|
const template = target.path === 'codecov.yml' && source?.quality?.codecov.enabled === false
|
|
508
538
|
? '{{header}}\n# Codecov is disabled in the manifest.\ncodecov:\n require_ci_to_pass: false\n'
|
|
509
539
|
: readTemplate(target.template);
|
|
@@ -519,7 +549,7 @@ function renderOnce(hfs, presets, { sonarKey, readTemplate = readBundled, manife
|
|
|
519
549
|
const markers = target.markdown ? { begin: '<!-- >>> starci app sync (managed block; do not edit) >>> -->', end: '<!-- <<< starci app sync <<< -->' } : { begin: BLOCK_BEGIN, end: BLOCK_END };
|
|
520
550
|
const spacing = target.markdown ? '\n' : '';
|
|
521
551
|
const managed = target.mode === 'block' ? `${markers.begin}\n${spacing}${trailingNewline(body)}${spacing}${markers.end}\n` : body;
|
|
522
|
-
return { path: where, scope, mode: target.mode, markdown: target.markdown, markers, content: managed, hash: hashOf(managed) };
|
|
552
|
+
return { path: where, scope, mode: target.mode, markdown: target.markdown, markers, content: managed, hash: hashOf(managed), ...(target.group === undefined ? {} : { group: target.group }) };
|
|
523
553
|
})];
|
|
524
554
|
});
|
|
525
555
|
}
|
|
@@ -657,6 +687,8 @@ const LITE_RETIRED_DIRS = Object.freeze(['supabase/types']);
|
|
|
657
687
|
/** The files a lite app's sync retires, each with the reason the dry run prints. */
|
|
658
688
|
const LITE_RETIRED_FILES = Object.freeze({
|
|
659
689
|
[RETIRED_DB_DOCKERFILE]: `the db image moved to ${DB_DOCKERFILE} (ruling lite-db-image-in-stacks)`,
|
|
690
|
+
'be/src/modules/domain/identity/persistence/entities/auth-user.entity.ts': 'the sign-in reaches accounts through the account doors (be/src/modules/domain/identity/persistence/account-doors.port.ts); the application role holds no grant on schema auth',
|
|
691
|
+
'be/src/modules/domain/identity/persistence/entities/auth-identity.entity.ts': 'the sign-in reaches accounts through the account doors (be/src/modules/domain/identity/persistence/account-doors.port.ts); the application role holds no grant on schema auth',
|
|
660
692
|
'.github/workflows/db-deploy.yml': 'the stack deploy migrates the database (a verified backup, the expand migrations before the services, the contract migrations after the accept; ruling app-cicd-auto-deploy)',
|
|
661
693
|
});
|
|
662
694
|
/** Why a retired file goes: a retired file's own reason, else the folder's. */
|
|
@@ -683,6 +715,16 @@ export function retiredFiles(root, hfs) {
|
|
|
683
715
|
walk(file);
|
|
684
716
|
return found;
|
|
685
717
|
}
|
|
718
|
+
/** The writes of `targets` and the deletions of `retired` the compile guard judges: every file sync would change, with its managed group. */
|
|
719
|
+
export function guardedWrites(root, targets, retired) {
|
|
720
|
+
const changed = checkTargets(root, targets).filter(result => result.status !== 'ok' && isGuardedSource(result.path));
|
|
721
|
+
const writes = changed.map((result) => {
|
|
722
|
+
const target = targets.find(candidate => candidate.path === result.path);
|
|
723
|
+
const file = path.join(root, target.path);
|
|
724
|
+
return { path: target.path, group: target.group ?? target.path, text: writtenContent(target, fs.existsSync(file) ? lf(fs.readFileSync(file, 'utf8')) : '') };
|
|
725
|
+
});
|
|
726
|
+
return [...writes, ...retired.map((file) => ({ path: file, group: RETIRED_GROUP, text: null }))];
|
|
727
|
+
}
|
|
686
728
|
/** `starci app sync [--execute] [--cwd <dir>]` at the app root: the dry run reports drift, --execute writes; returns 0 clean, 1 drift, 2 usage. */
|
|
687
729
|
function syncOptions(argv) {
|
|
688
730
|
const write = argv.includes('--execute');
|
|
@@ -707,7 +749,7 @@ function reportSyncCheck(root, targets, out, retired = []) {
|
|
|
707
749
|
out(`starci app sync: ${results.length - bad.length} of ${results.length} in sync${bad.length ? '; run "npx --no-install starci app sync --execute"' : ''}`);
|
|
708
750
|
return bad.length || retired.length ? 1 : 0;
|
|
709
751
|
}
|
|
710
|
-
export async function runSync(argv, { cwd = process.cwd(), out = (line) => process.stdout.write(`${line}\n`), presets, parseYaml } = {}) {
|
|
752
|
+
export async function runSync(argv, { cwd = process.cwd(), out = (line) => process.stdout.write(`${line}\n`), presets, parseYaml, loadTs } = {}) {
|
|
711
753
|
const options = syncOptions(argv);
|
|
712
754
|
if (!options) {
|
|
713
755
|
out('usage: starci app sync [--execute] [--cwd <dir>]');
|
|
@@ -723,18 +765,27 @@ export async function runSync(argv, { cwd = process.cwd(), out = (line) => proce
|
|
|
723
765
|
for (const notice of new Set(notices))
|
|
724
766
|
out(notice);
|
|
725
767
|
if (write) {
|
|
726
|
-
const
|
|
768
|
+
const { held, unchecked } = guardWrites(root, guardedWrites(root, targets, retired), loadTs);
|
|
769
|
+
const holds = new Set(held.flatMap(group => group.files));
|
|
770
|
+
const free = targets.filter(target => !holds.has(target.path));
|
|
771
|
+
const written = writeTargets(root, free);
|
|
772
|
+
const removed = retired.filter(file => !holds.has(file));
|
|
727
773
|
for (const file of written)
|
|
728
774
|
out(`wrote ${file}`);
|
|
729
|
-
for (const file of
|
|
775
|
+
for (const file of removed) {
|
|
730
776
|
fs.rmSync(path.join(root, file));
|
|
731
777
|
out(`removed ${file} (retired)`);
|
|
732
778
|
}
|
|
733
779
|
for (const dir of LITE_RETIRED_DIRS)
|
|
734
|
-
if (
|
|
780
|
+
if (removed.length && fs.existsSync(path.join(root, dir)) && !fs.readdirSync(path.join(root, dir)).length)
|
|
735
781
|
fs.rmdirSync(path.join(root, dir));
|
|
736
|
-
|
|
737
|
-
|
|
782
|
+
for (const project of unchecked)
|
|
783
|
+
out(`notice: the writes compiled by ${project} were not type-checked: its TypeScript is not installed (run npm install, then "npx --no-install starci app check")`);
|
|
784
|
+
for (const group of held)
|
|
785
|
+
for (const line of heldLines(group))
|
|
786
|
+
out(line);
|
|
787
|
+
out(`starci app sync: ${written.length} written, ${free.length - written.length} already in sync${holds.size ? `, ${holds.size} held` : ''}`);
|
|
788
|
+
return held.length ? 1 : 0;
|
|
738
789
|
}
|
|
739
790
|
return reportSyncCheck(root, targets, out, retired);
|
|
740
791
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
-- The account doors of a lite app: the only way app_be reaches auth.users and auth.identities (it holds no grant on schema auth). Eight
|
|
2
|
+
-- SECURITY DEFINER functions with a pinned search_path, EXECUTE revoked from public and the browser roles and granted to app_be alone: an
|
|
3
|
+
-- account comes back only for an exact address or id, a hash only for an exact address, and no door writes role or app metadata; the hash
|
|
4
|
+
-- is written only by a new account and by be_auth_set_password.
|
|
5
|
+
-- The text is the canon owner of the doors (templates/app/auth-doors.sql.partial); the back end calls them through persistence/account-doors.port.ts.
|
|
6
|
+
|
|
7
|
+
-- Looks up an address in lower case; the bootstrap index serves email as written, not lower(email).
|
|
8
|
+
create index if not exists users_email_lower_idx on auth.users (lower(email)) where is_anonymous = false;
|
|
9
|
+
|
|
10
|
+
-- A person by sign-in address (lower-cased), with the stored bcrypt hash so the back end can verify it.
|
|
11
|
+
CREATE FUNCTION public.be_auth_user_by_email(p_email text)
|
|
12
|
+
RETURNS TABLE(id uuid, email text, encrypted_password text, email_confirmed_at timestamp with time zone, disabled boolean)
|
|
13
|
+
LANGUAGE sql STABLE SECURITY DEFINER
|
|
14
|
+
SET search_path TO ''
|
|
15
|
+
AS $$
|
|
16
|
+
select u.id, u.email::text, u.encrypted_password::text, u.email_confirmed_at,
|
|
17
|
+
(u.deleted_at is not null or (u.banned_until is not null and u.banned_until > now()))
|
|
18
|
+
from auth.users u
|
|
19
|
+
where lower(u.email) = p_email and u.is_anonymous = false
|
|
20
|
+
order by u.created_at asc nulls last
|
|
21
|
+
limit 1;
|
|
22
|
+
$$;
|
|
23
|
+
|
|
24
|
+
-- One person by id.
|
|
25
|
+
CREATE FUNCTION public.be_auth_user_by_id(p_user uuid)
|
|
26
|
+
RETURNS TABLE(id uuid, email text, last_sign_in_at timestamp with time zone, meta jsonb, disabled boolean)
|
|
27
|
+
LANGUAGE sql STABLE SECURITY DEFINER
|
|
28
|
+
SET search_path TO ''
|
|
29
|
+
AS $$
|
|
30
|
+
select u.id, u.email::text, u.last_sign_in_at, coalesce(u.raw_user_meta_data, '{}'::jsonb),
|
|
31
|
+
(u.deleted_at is not null or (u.banned_until is not null and u.banned_until > now()))
|
|
32
|
+
from auth.users u where u.id = p_user;
|
|
33
|
+
$$;
|
|
34
|
+
|
|
35
|
+
-- The person a Google account is linked to, if any.
|
|
36
|
+
CREATE FUNCTION public.be_auth_user_by_google(p_provider_id text) RETURNS uuid
|
|
37
|
+
LANGUAGE sql STABLE SECURITY DEFINER
|
|
38
|
+
SET search_path TO ''
|
|
39
|
+
AS $$
|
|
40
|
+
select i.user_id from auth.identities i where i.provider = 'google' and i.provider_id = p_provider_id limit 1;
|
|
41
|
+
$$;
|
|
42
|
+
|
|
43
|
+
-- Creates a person; a null hash is an invited or Google-only account that has no password yet.
|
|
44
|
+
CREATE FUNCTION public.be_auth_create_user(p_id uuid, p_email text, p_hash text, p_confirmed boolean, p_meta jsonb, p_now timestamp with time zone)
|
|
45
|
+
RETURNS uuid
|
|
46
|
+
LANGUAGE sql VOLATILE SECURITY DEFINER
|
|
47
|
+
SET search_path TO ''
|
|
48
|
+
AS $$
|
|
49
|
+
insert into auth.users (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at, invited_at,
|
|
50
|
+
raw_app_meta_data, raw_user_meta_data, created_at, updated_at)
|
|
51
|
+
values ('00000000-0000-0000-0000-000000000000', p_id, 'authenticated', 'authenticated', p_email, p_hash,
|
|
52
|
+
case when p_confirmed then p_now end, case when p_hash is null and not p_confirmed then p_now end,
|
|
53
|
+
'{"provider":"email","providers":["email"]}'::jsonb, p_meta, p_now, p_now)
|
|
54
|
+
returning id;
|
|
55
|
+
$$;
|
|
56
|
+
|
|
57
|
+
-- Links a Google account to a person.
|
|
58
|
+
CREATE FUNCTION public.be_auth_link_google(p_user uuid, p_provider_id text, p_data jsonb, p_now timestamp with time zone) RETURNS void
|
|
59
|
+
LANGUAGE sql VOLATILE SECURITY DEFINER
|
|
60
|
+
SET search_path TO ''
|
|
61
|
+
AS $$
|
|
62
|
+
insert into auth.identities (provider_id, user_id, identity_data, provider, last_sign_in_at, created_at, updated_at)
|
|
63
|
+
values (p_provider_id, p_user, p_data, 'google', p_now, p_now, p_now)
|
|
64
|
+
on conflict (provider_id, provider) do update set last_sign_in_at = p_now, updated_at = p_now;
|
|
65
|
+
$$;
|
|
66
|
+
|
|
67
|
+
-- Records a sign-in; a verified Google address also confirms the mailbox; profile keys only fill what the person has not set.
|
|
68
|
+
CREATE FUNCTION public.be_auth_touch_user(p_user uuid, p_now timestamp with time zone, p_confirm boolean, p_meta jsonb) RETURNS void
|
|
69
|
+
LANGUAGE sql VOLATILE SECURITY DEFINER
|
|
70
|
+
SET search_path TO ''
|
|
71
|
+
AS $$
|
|
72
|
+
update auth.users
|
|
73
|
+
set last_sign_in_at = p_now,
|
|
74
|
+
updated_at = p_now,
|
|
75
|
+
email_confirmed_at = case when p_confirm then coalesce(email_confirmed_at, p_now) else email_confirmed_at end,
|
|
76
|
+
raw_user_meta_data = p_meta || coalesce(raw_user_meta_data, '{}'::jsonb)
|
|
77
|
+
where id = p_user;
|
|
78
|
+
$$;
|
|
79
|
+
|
|
80
|
+
-- Stamps the confirmation of a mailbox (a spent confirmation link); a mailbox that is already confirmed keeps its date and the sign-in record is untouched.
|
|
81
|
+
CREATE FUNCTION public.be_auth_confirm_user(p_user uuid, p_now timestamp with time zone) RETURNS void
|
|
82
|
+
LANGUAGE sql VOLATILE SECURITY DEFINER
|
|
83
|
+
SET search_path TO ''
|
|
84
|
+
AS $$
|
|
85
|
+
update auth.users set email_confirmed_at = coalesce(email_confirmed_at, p_now), updated_at = p_now where id = p_user;
|
|
86
|
+
$$;
|
|
87
|
+
|
|
88
|
+
-- Sets a new password hash; when p_confirm is true (a spent reset link proved the mailbox) the address is confirmed too.
|
|
89
|
+
CREATE FUNCTION public.be_auth_set_password(p_user uuid, p_hash text, p_confirm boolean, p_now timestamp with time zone) RETURNS void
|
|
90
|
+
LANGUAGE sql VOLATILE SECURITY DEFINER
|
|
91
|
+
SET search_path TO ''
|
|
92
|
+
AS $$
|
|
93
|
+
update auth.users
|
|
94
|
+
set encrypted_password = p_hash,
|
|
95
|
+
email_confirmed_at = case when p_confirm then coalesce(email_confirmed_at, p_now) else email_confirmed_at end,
|
|
96
|
+
updated_at = p_now
|
|
97
|
+
where id = p_user;
|
|
98
|
+
$$;
|
|
99
|
+
|
|
100
|
+
REVOKE EXECUTE ON FUNCTION public.be_auth_user_by_email(text) FROM PUBLIC, anon, authenticated;
|
|
101
|
+
GRANT EXECUTE ON FUNCTION public.be_auth_user_by_email(text) TO app_be;
|
|
102
|
+
REVOKE EXECUTE ON FUNCTION public.be_auth_user_by_id(uuid) FROM PUBLIC, anon, authenticated;
|
|
103
|
+
GRANT EXECUTE ON FUNCTION public.be_auth_user_by_id(uuid) TO app_be;
|
|
104
|
+
REVOKE EXECUTE ON FUNCTION public.be_auth_user_by_google(text) FROM PUBLIC, anon, authenticated;
|
|
105
|
+
GRANT EXECUTE ON FUNCTION public.be_auth_user_by_google(text) TO app_be;
|
|
106
|
+
REVOKE EXECUTE ON FUNCTION public.be_auth_create_user(uuid, text, text, boolean, jsonb, timestamp with time zone) FROM PUBLIC, anon, authenticated;
|
|
107
|
+
GRANT EXECUTE ON FUNCTION public.be_auth_create_user(uuid, text, text, boolean, jsonb, timestamp with time zone) TO app_be;
|
|
108
|
+
REVOKE EXECUTE ON FUNCTION public.be_auth_link_google(uuid, text, jsonb, timestamp with time zone) FROM PUBLIC, anon, authenticated;
|
|
109
|
+
GRANT EXECUTE ON FUNCTION public.be_auth_link_google(uuid, text, jsonb, timestamp with time zone) TO app_be;
|
|
110
|
+
REVOKE EXECUTE ON FUNCTION public.be_auth_touch_user(uuid, timestamp with time zone, boolean, jsonb) FROM PUBLIC, anon, authenticated;
|
|
111
|
+
GRANT EXECUTE ON FUNCTION public.be_auth_touch_user(uuid, timestamp with time zone, boolean, jsonb) TO app_be;
|
|
112
|
+
REVOKE EXECUTE ON FUNCTION public.be_auth_confirm_user(uuid, timestamp with time zone) FROM PUBLIC, anon, authenticated;
|
|
113
|
+
GRANT EXECUTE ON FUNCTION public.be_auth_confirm_user(uuid, timestamp with time zone) TO app_be;
|
|
114
|
+
REVOKE EXECUTE ON FUNCTION public.be_auth_set_password(uuid, text, boolean, timestamp with time zone) FROM PUBLIC, anon, authenticated;
|
|
115
|
+
GRANT EXECUTE ON FUNCTION public.be_auth_set_password(uuid, text, boolean, timestamp with time zone) TO app_be;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"build:be": "cd be && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
|
|
1
|
+
"build:be": "{{modulesFirst}}cd be && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
|
|
2
2
|
"build:fe": "{{buildFe}}",
|
|
3
3
|
"codegen": "node scripts/codegen.mjs",
|
|
4
4
|
"codegen:graphql": "node scripts/codegen.mjs --graphql",
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
{{appScripts}}
|
|
5
5
|
{{> app/package-scripts/core.json}}
|
|
6
6
|
"typecheck:tests": "tsc -p be/src/tests/tsconfig.json",
|
|
7
|
-
"test": "cd be && jest --selectProjects unit --coverage
|
|
7
|
+
"test": "{{modulesFirst}}cd be && jest --selectProjects unit --coverage",
|
|
8
8
|
"test:integration": "npm run typecheck:tests && cd be && jest --selectProjects integration",
|
|
9
9
|
"test:e2e": "npm run typecheck:tests && cd be && jest --selectProjects e2e",
|
|
10
10
|
"test:contract": "npm run typecheck:tests && cd be && jest --selectProjects contract",
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
{{appScripts}}
|
|
5
5
|
{{> app/package-scripts/core.json}}
|
|
6
6
|
"typecheck:tests": "tsc -p be/src/tests/tsconfig.json",
|
|
7
|
-
"test": "cd be && jest --selectProjects unit --coverage
|
|
7
|
+
"test": "{{modulesFirst}}cd be && jest --selectProjects unit --coverage",
|
|
8
8
|
"test:integration": "npm run typecheck:tests && cd be && jest --selectProjects integration",
|
|
9
9
|
{{> app/package-scripts/db.json}}
|
|
10
10
|
{{backingScripts}}
|
|
@@ -58,8 +58,11 @@ environments:
|
|
|
58
58
|
db: 54322
|
|
59
59
|
api: 3001
|
|
60
60
|
web: 3000
|
|
61
|
+
mail: 1025
|
|
62
|
+
mailui: 8025
|
|
61
63
|
services:
|
|
62
64
|
- {name: db, wait: healthy}
|
|
65
|
+
- {name: mail, wait: healthy}
|
|
63
66
|
database:
|
|
64
67
|
service: db
|
|
65
68
|
port: db
|
|
@@ -97,6 +100,11 @@ environments:
|
|
|
97
100
|
AUTH_WEB_ORIGIN: "http://localhost:${port.web}"
|
|
98
101
|
AUTH_JWT_AUDIENCE: {{project}}
|
|
99
102
|
AUTH_JWT_KEY_ID: dev-1
|
|
103
|
+
MAIL_HOST: 127.0.0.1
|
|
104
|
+
MAIL_PORT: "${port.mail}"
|
|
105
|
+
MAIL_SECURITY: none
|
|
106
|
+
MAIL_FROM: no-reply@{{project}}.example.invalid
|
|
107
|
+
MAIL_FROM_NAME: {{project}}
|
|
100
108
|
STORAGE_ENDPOINT: "http://127.0.0.1:9000"
|
|
101
109
|
STORAGE_REGION: us-east-1
|
|
102
110
|
STORAGE_BUCKET: {{project}}-dev
|
|
@@ -120,6 +128,7 @@ environments:
|
|
|
120
128
|
origins:
|
|
121
129
|
- {name: web, url: "http://localhost:${port.web}"}
|
|
122
130
|
- {name: api, url: "http://localhost:${port.api}"}
|
|
131
|
+
- {name: mail, url: "http://localhost:${port.mailui}"}
|
|
123
132
|
production:
|
|
124
133
|
status: supported
|
|
125
134
|
runtime: docker-swarm
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The dev environment is one command: `starci app stack up --env dev --execute`. It checks the machine, opens the dev custody, runs the setup
|
|
4
4
|
commands, starts the dependencies in Docker Linux containers (the `db` service: the official postgres image plus pgvector, built from
|
|
5
|
-
`.starcistacks/images/db/Dockerfile`), gives the environment its own database role, migrates, runs the seed, starts the api and web
|
|
5
|
+
`.starcistacks/images/db/Dockerfile`; the `mail` service: Mailpit, the dev mail sink), gives the environment its own database role, migrates, runs the seed, starts the api and web
|
|
6
6
|
processes natively and waits for readiness. What it runs is the `local` block of `environments.dev` in `.starcistacks/application-stacks.yaml`
|
|
7
7
|
(PROJECT-STACKS-6 in contexts/project/stacks.md); this runbook never carries a docker command, an agent never runs docker by hand.
|
|
8
8
|
|
|
@@ -26,5 +26,7 @@ another environment.
|
|
|
26
26
|
| down | `starci app stack down --env dev --execute`: ends the processes and stops the compose project; volumes and sealed values stay. |
|
|
27
27
|
| verification | `starci app stack verify --env dev`: the declared liveness and readiness probes after a real start, then `npm run lint` from the app root. |
|
|
28
28
|
|
|
29
|
+
**Mail.** The confirmation and reset mails of the sign-in go to the `mail` service on its SMTP port (`MAIL_HOST`, `MAIL_PORT`, `MAIL_SECURITY: none`: a plain channel that only the loopback sink accepts) and nothing leaves the machine. Open the web UI at the `mail` origin `up` prints (`http://localhost:8025` by default), sign up in the web app and follow the link from the message there. A deployed environment names a real SMTP server and `MAIL_SECURITY: starttls` or `tls`.
|
|
30
|
+
|
|
29
31
|
The database volume `db-data` is rebuildable development data: `starci app db migrate` recreates its schema from `db/bootstrap.sql` and
|
|
30
32
|
`db/migrations`. Key names of the environment are in `.starcistacks/dev/runtime/env/KEYS.md`.
|