@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,39 @@
|
|
|
1
|
+
// local-ports.mjs - free-port checks of a dev environment and the name of whatever holds a port that is not free.
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import { firstLine, lines as splitLines } from './context.mjs';
|
|
4
|
+
/** True when nothing listens on the loopback `port` (a bind attempt, so a wildcard listener counts too). */
|
|
5
|
+
export const bindFree = (port) => new Promise((resolve) => {
|
|
6
|
+
const server = net.createServer();
|
|
7
|
+
server.once('error', () => resolve(false));
|
|
8
|
+
server.listen(port, '127.0.0.1', () => server.close(() => resolve(true)));
|
|
9
|
+
});
|
|
10
|
+
/** True when something accepts a TCP connection on the loopback `port`. */
|
|
11
|
+
export const tcpOpen = (port) => new Promise((resolve) => {
|
|
12
|
+
const socket = net.connect({ port, host: '127.0.0.1' });
|
|
13
|
+
socket.setTimeout(1000);
|
|
14
|
+
socket.once('connect', () => { socket.destroy(); resolve(true); });
|
|
15
|
+
socket.once('timeout', () => { socket.destroy(); resolve(false); });
|
|
16
|
+
socket.once('error', () => resolve(false));
|
|
17
|
+
});
|
|
18
|
+
/** What holds `port`: a docker container (by published port), else the process (netstat/tasklist on Windows, lsof elsewhere), else "an unknown process". */
|
|
19
|
+
export function holderOf(ctx, port) {
|
|
20
|
+
const container = ctx.spawn('docker', ['ps', '--filter', `publish=${port}`, '--format', '{{.Names}}']);
|
|
21
|
+
const name = firstLine(container.stdout);
|
|
22
|
+
if (container.status === 0 && name)
|
|
23
|
+
return `container ${name}`;
|
|
24
|
+
if (ctx.platform === 'win32') {
|
|
25
|
+
const table = ctx.spawn('netstat', ['-ano', '-p', 'tcp']);
|
|
26
|
+
const row = splitLines(table.stdout).map((line) => line.trim().split(/\s+/u)).find((cells) => cells[3] === 'LISTENING' && cells[1]?.endsWith(`:${port}`));
|
|
27
|
+
const pid = row?.[4];
|
|
28
|
+
if (pid) {
|
|
29
|
+
const task = ctx.spawn('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH']);
|
|
30
|
+
const image = /^"([^"]+)"/u.exec(firstLine(task.stdout))?.[1];
|
|
31
|
+
return image ? `process ${image} (pid ${pid})` : `process pid ${pid}`;
|
|
32
|
+
}
|
|
33
|
+
return 'an unknown process';
|
|
34
|
+
}
|
|
35
|
+
const listing = ctx.spawn('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fpc']);
|
|
36
|
+
const pid = /^p(\d+)/mu.exec(listing.stdout)?.[1];
|
|
37
|
+
const command = /^c(.+)$/mu.exec(listing.stdout)?.[1];
|
|
38
|
+
return pid ? `process ${command ?? 'unknown'} (pid ${pid})` : 'an unknown process';
|
|
39
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { StackContext } from './context.mjs';
|
|
2
|
+
import type { ActionResult } from './remote.mjs';
|
|
3
|
+
import type { LocalConfig } from './local-config.mjs';
|
|
4
|
+
import type { Custody } from './local-custody.mjs';
|
|
5
|
+
import type { Prerequisite } from './local-host.mjs';
|
|
6
|
+
import type { LocalDeps } from './local-run.mjs';
|
|
7
|
+
/** The prerequisite table of the environment as rows and printed lines. */
|
|
8
|
+
export declare function preflight(ctx: StackContext, local: LocalConfig, deps: LocalDeps): Promise<{
|
|
9
|
+
rows: Prerequisite[];
|
|
10
|
+
lines: string[];
|
|
11
|
+
ok: boolean;
|
|
12
|
+
}>;
|
|
13
|
+
/** The refusal result of a failed preflight: the table with its owner steps, then the code. */
|
|
14
|
+
export declare const hostRefusal: (lines: string[]) => ActionResult;
|
|
15
|
+
/** Runs the setup commands (install, codegen); a command with `skipIfExists` that finds its marker is skipped. */
|
|
16
|
+
export declare function runSetup(ctx: StackContext, local: LocalConfig, custody: Custody | null): Promise<string[]>;
|
|
17
|
+
/** Builds the images that have a build context and pulls the rest. */
|
|
18
|
+
export declare function prepareImages(ctx: StackContext, local: LocalConfig, custody: Custody | null): Promise<string[]>;
|
|
19
|
+
/** `prepare` for a dev environment. */
|
|
20
|
+
export declare function prepareLocal(ctx: StackContext, local: LocalConfig, { execute }?: {
|
|
21
|
+
execute?: boolean;
|
|
22
|
+
}, deps?: LocalDeps): Promise<ActionResult>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// local-prepare.mjs - `starci app stack prepare --env <dev env> [--execute]` (PROJECT-STACKS-6): checks the host, opens the dev custody, runs the
|
|
2
|
+
// declared setup commands (install, codegen) and builds or pulls the compose images. A dry run reports the table and the plan and changes nothing.
|
|
3
|
+
// It refuses on a missing prerequisite, a held port, a missing sealed value or an identity this machine lacks, and never generates a replacement.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { StackError } from './errors.mjs';
|
|
7
|
+
import { missingKeys, missingStep, neededKeys, openCustody } from './local-custody.mjs';
|
|
8
|
+
import { hostPrerequisites, tableLines } from './local-host.mjs';
|
|
9
|
+
import { commandDigest, compose, composeValues, runCommand, runEnvironment, stateOf } from './local-run.mjs';
|
|
10
|
+
import { readRecord, writeRecord } from './local-state.mjs';
|
|
11
|
+
/** The prerequisite table of the environment as rows and printed lines. */
|
|
12
|
+
export async function preflight(ctx, local, deps) {
|
|
13
|
+
const rows = await hostPrerequisites(ctx, local, { repoRoot: local.repoRoot, stateDir: stateOf(ctx, local), secretsDir: local.secretsDir }, deps);
|
|
14
|
+
return { rows, lines: tableLines(rows), ok: rows.every((row) => row.ok) };
|
|
15
|
+
}
|
|
16
|
+
/** The refusal result of a failed preflight: the table with its owner steps, then the code. */
|
|
17
|
+
export const hostRefusal = (lines) => ({ ok: false, lines, refused: { code: 'STACK_LOCAL_HOST', message: 'this machine is missing what the environment needs; run the owner steps above, then retry' } });
|
|
18
|
+
/** Runs the setup commands (install, codegen); a command with `skipIfExists` that finds its marker is skipped. */
|
|
19
|
+
export async function runSetup(ctx, local, custody) {
|
|
20
|
+
const out = [];
|
|
21
|
+
const dir = stateOf(ctx, local);
|
|
22
|
+
const record = readRecord(dir);
|
|
23
|
+
for (const run of local.setup) {
|
|
24
|
+
if (run.skipIfExists && fs.existsSync(path.join(local.repoRoot, run.cwd, run.skipIfExists))) {
|
|
25
|
+
out.push(`setup ${run.name}: skipped (${run.skipIfExists} exists)`);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
await runCommand(ctx, local, run, runEnvironment(ctx, local, run, custody, null));
|
|
29
|
+
record.receipts.setup[run.name] = { at: new Date(ctx.now()).toISOString(), digest: commandDigest(run) };
|
|
30
|
+
out.push(`setup ${run.name}: done`);
|
|
31
|
+
}
|
|
32
|
+
writeRecord(dir, record);
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/** Builds the images that have a build context and pulls the rest. */
|
|
36
|
+
export async function prepareImages(ctx, local, custody) {
|
|
37
|
+
const values = composeValues(local, custody);
|
|
38
|
+
const build = await compose(ctx, local, ['build'], { values, heavy: true, label: 'compose build' });
|
|
39
|
+
const pull = await compose(ctx, local, ['pull', '--ignore-buildable', '--quiet'], { values, heavy: true, label: 'compose pull' });
|
|
40
|
+
const lines = [build.status === 0 ? 'images: built' : `images: build failed (${(build.stderr.trim().split(/\r?\n/u)[0] ?? '').slice(0, 160)})`];
|
|
41
|
+
lines.push(pull.status === 0 ? 'images: pulled' : `images: pull failed (${(pull.stderr.trim().split(/\r?\n/u)[0] ?? '').slice(0, 160)})`);
|
|
42
|
+
if (build.status !== 0 || pull.status !== 0)
|
|
43
|
+
throw new StackError('STACK_LOCAL_IMAGES', lines.join('; '), 1);
|
|
44
|
+
return lines;
|
|
45
|
+
}
|
|
46
|
+
/** `prepare` for a dev environment. */
|
|
47
|
+
export async function prepareLocal(ctx, local, { execute = false } = {}, deps = {}) {
|
|
48
|
+
const host = await preflight(ctx, local, deps);
|
|
49
|
+
if (!host.ok)
|
|
50
|
+
return hostRefusal(host.lines);
|
|
51
|
+
const lines = [...host.lines];
|
|
52
|
+
const missing = missingKeys(local);
|
|
53
|
+
lines.push(`custody: ${neededKeys(local).length} key(s) read from ${local.env}${missing.length ? `; missing ${missing.join(', ')} (${missingStep(local, missing)})` : ', all sealed'}`);
|
|
54
|
+
if (!execute) {
|
|
55
|
+
lines.push(...local.setup.map((run) => `plan setup ${run.name}: ${run.argv.join(' ')}`), 'plan images: docker compose build and pull', 'dry run: nothing was changed; arm it with --execute');
|
|
56
|
+
return { ok: missing.length === 0, lines, data: { rows: host.rows } };
|
|
57
|
+
}
|
|
58
|
+
const custody = openCustody(ctx, local);
|
|
59
|
+
lines.push(`custody: ${custody.values.size} key file(s) written to the host-local directory`);
|
|
60
|
+
lines.push(...await runSetup(ctx, local, custody), ...await prepareImages(ctx, local, custody));
|
|
61
|
+
return { ok: true, lines, data: { rows: host.rows } };
|
|
62
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { RunResult, StackContext } from './context.mjs';
|
|
2
|
+
import type { LocalConfig } from './local-config.mjs';
|
|
3
|
+
import type { Custody } from './local-custody.mjs';
|
|
4
|
+
import { hostTool } from './local-host.mjs';
|
|
5
|
+
import type { HostDeps } from './local-host.mjs';
|
|
6
|
+
import type { HttpProbe, LocalCommand } from '../runtime/scripts/hfs/stack-local-config.mjs';
|
|
7
|
+
/** The seams a spec replaces besides the context's own. */
|
|
8
|
+
export interface LocalDeps extends HostDeps {
|
|
9
|
+
alive?: ((pid: number) => boolean) | undefined;
|
|
10
|
+
kill?: ((pid: number) => void) | undefined;
|
|
11
|
+
tcpOpen?: ((port: number) => Promise<boolean>) | undefined;
|
|
12
|
+
migrate?: ((urlEnv: string, env: NodeJS.ProcessEnv, cwd: string) => Promise<number>) | undefined;
|
|
13
|
+
}
|
|
14
|
+
export declare const DEFAULT_TIMEOUT_SECONDS = 180;
|
|
15
|
+
export declare const stateOf: (ctx: StackContext, local: LocalConfig) => string;
|
|
16
|
+
/** The data directory of the environment under its state directory (`${data}` in a declared value, `STACK_DATA_DIR` in compose). */
|
|
17
|
+
export declare const dataOf: (ctx: StackContext, local: LocalConfig) => string;
|
|
18
|
+
/** Expands `${port.<name>}` and `${data}` in a declared value. */
|
|
19
|
+
export declare const expand: (ctx: StackContext, local: LocalConfig, value: string) => string;
|
|
20
|
+
/** Creates the declared data directories. */
|
|
21
|
+
export declare function makeDataDirectories(ctx: StackContext, local: LocalConfig): void;
|
|
22
|
+
/** `STACK_PORT_<NAME>` for every declared port: the names compose files interpolate (`127.0.0.1:${STACK_PORT_DB}:5432`). */
|
|
23
|
+
export declare const portEnvironment: (ports: Readonly<Record<string, number>>) => Record<string, string>;
|
|
24
|
+
/** `STACK_GRANT_<NAME>` for every least grant: the private directory a compose service mounts to read only its own keys. */
|
|
25
|
+
export declare function grantEnvironment(ctx: StackContext, local: LocalConfig): Record<string, string>;
|
|
26
|
+
/** The docker compose argv of the environment's project and files. */
|
|
27
|
+
export declare const composeArgs: (local: LocalConfig, args: readonly string[]) => string[];
|
|
28
|
+
/** Runs one docker compose command; `values` are the compose-interpolated custody values, passed in memory only. */
|
|
29
|
+
export declare function compose(ctx: StackContext, local: LocalConfig, args: readonly string[], { values, heavy, label, input }?: {
|
|
30
|
+
values?: Record<string, string>;
|
|
31
|
+
heavy?: boolean;
|
|
32
|
+
label?: string;
|
|
33
|
+
input?: string;
|
|
34
|
+
}): Promise<RunResult>;
|
|
35
|
+
/** The compose-interpolated values of the environment (the keys `composeEnv` maps), from the opened custody. */
|
|
36
|
+
export declare function composeValues(local: LocalConfig, custody: Custody | null): Record<string, string>;
|
|
37
|
+
/** The environment a command or process runs with: ports expanded, custody as `<KEY>_FILE`, the database URL as the environment declares. */
|
|
38
|
+
export declare function runEnvironment(ctx: StackContext, local: LocalConfig, run: LocalCommand, custody: Custody | null, role: 'owner' | 'app' | null): NodeJS.ProcessEnv;
|
|
39
|
+
/** The loopback connection string of the environment's own database role, built in memory from custody. */
|
|
40
|
+
export declare function databaseUrl(local: LocalConfig, custody: Custody, role?: 'owner' | 'app'): string | null;
|
|
41
|
+
/** Runs one setup or seed command to its end, through the governor when its class is heavy. A non-zero exit is a failure naming the command and its first error line. */
|
|
42
|
+
export declare function runCommand(ctx: StackContext, local: LocalConfig, run: LocalCommand, env: NodeJS.ProcessEnv): Promise<void>;
|
|
43
|
+
/** The digest of a command's argv and cwd: the key of its receipt. */
|
|
44
|
+
export declare const commandDigest: (run: LocalCommand) => string;
|
|
45
|
+
/** True when `pid` is alive. */
|
|
46
|
+
export declare const isAlive: (pid: number) => boolean;
|
|
47
|
+
/** Starts a native process detached from this command, its output in the environment's log. Returns the pid. */
|
|
48
|
+
export declare function startProcess(ctx: StackContext, local: LocalConfig, run: LocalCommand, env: NodeJS.ProcessEnv): number;
|
|
49
|
+
/** Ends a native process and its children. */
|
|
50
|
+
export declare function stopProcess(ctx: Pick<StackContext, 'spawn' | 'platform'>, pid: number, deps?: LocalDeps): void;
|
|
51
|
+
/** One HTTP probe against its expanded URL; the verdict and a detail line. */
|
|
52
|
+
export declare function probe(ctx: StackContext, local: LocalConfig, row: HttpProbe): Promise<{
|
|
53
|
+
ok: boolean;
|
|
54
|
+
detail: string;
|
|
55
|
+
}>;
|
|
56
|
+
/** Polls a probe until it passes or the deadline passes. */
|
|
57
|
+
export declare function waitFor(ctx: StackContext, deadline: number, attempt: () => Promise<{
|
|
58
|
+
ok: boolean;
|
|
59
|
+
detail: string;
|
|
60
|
+
}>): Promise<{
|
|
61
|
+
ok: boolean;
|
|
62
|
+
detail: string;
|
|
63
|
+
}>;
|
|
64
|
+
/** The per-service readiness the environment declares: compose health, an open port, or just started. */
|
|
65
|
+
export declare function serviceReady(ctx: StackContext, local: LocalConfig, name: string, deadline: number, deps?: LocalDeps): Promise<{
|
|
66
|
+
ok: boolean;
|
|
67
|
+
detail: string;
|
|
68
|
+
}>;
|
|
69
|
+
/** Masks the value of anything that reads like a credential assignment, so a log tail never carries one. */
|
|
70
|
+
export declare const redact: (line: string) => string;
|
|
71
|
+
export { hostTool };
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// local-run.mjs - what the dev verbs share: the compose command line of an environment, governed heavy runs, native process control,
|
|
2
|
+
// HTTP probes and the redaction of logs. Nothing here prints a value; every command is an argv list run without a shell.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { firstLine } from './context.mjs';
|
|
7
|
+
import { materializedDirectory } from '../runtime/scripts/hfs/secret-files.mjs';
|
|
8
|
+
import { failure, refusal } from './errors.mjs';
|
|
9
|
+
import { fileEnvironment, grantDirectory } from './local-custody.mjs';
|
|
10
|
+
import { hostTool, launch } from './local-host.mjs';
|
|
11
|
+
import { tcpOpen } from './local-ports.mjs';
|
|
12
|
+
import { logFile, stateDirectory } from './local-state.mjs';
|
|
13
|
+
import { expandPorts } from '../runtime/scripts/hfs/stack-local-config.mjs';
|
|
14
|
+
export const DEFAULT_TIMEOUT_SECONDS = 180;
|
|
15
|
+
export const stateOf = (ctx, local) => stateDirectory(ctx.env, local.project, local.env);
|
|
16
|
+
/** The data directory of the environment under its state directory (`${data}` in a declared value, `STACK_DATA_DIR` in compose). */
|
|
17
|
+
export const dataOf = (ctx, local) => path.join(stateOf(ctx, local), 'data');
|
|
18
|
+
/** Expands `${port.<name>}` and `${data}` in a declared value. */
|
|
19
|
+
export const expand = (ctx, local, value) => expandPorts(value, local.ports).replaceAll('${data}', dataOf(ctx, local).replaceAll('\\', '/'));
|
|
20
|
+
/** Creates the declared data directories. */
|
|
21
|
+
export function makeDataDirectories(ctx, local) {
|
|
22
|
+
for (const name of local.dirs)
|
|
23
|
+
fs.mkdirSync(path.join(dataOf(ctx, local), name), { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
/** `STACK_PORT_<NAME>` for every declared port: the names compose files interpolate (`127.0.0.1:${STACK_PORT_DB}:5432`). */
|
|
26
|
+
export const portEnvironment = (ports) => Object.fromEntries(Object.entries(ports).map(([name, port]) => [`STACK_PORT_${name.toUpperCase().replaceAll('-', '_')}`, String(port)]));
|
|
27
|
+
/** `STACK_GRANT_<NAME>` for every least grant: the private directory a compose service mounts to read only its own keys. */
|
|
28
|
+
export function grantEnvironment(ctx, local) {
|
|
29
|
+
if (local.grants.length === 0)
|
|
30
|
+
return {};
|
|
31
|
+
const directory = materializedDirectory(local.repoRoot, local.env, ctx.env, ctx.platform);
|
|
32
|
+
return Object.fromEntries(local.grants.map((grant) => [`STACK_GRANT_${grant.name.toUpperCase().replaceAll('-', '_')}`, grantDirectory(directory, grant.name).replaceAll('\\', '/')]));
|
|
33
|
+
}
|
|
34
|
+
/** The docker compose argv of the environment's project and files. */
|
|
35
|
+
export const composeArgs = (local, args) => ['compose', '-p', local.project, ...local.composeFiles.flatMap((file) => ['-f', file]), ...args];
|
|
36
|
+
/** Runs one docker compose command; `values` are the compose-interpolated custody values, passed in memory only. */
|
|
37
|
+
export async function compose(ctx, local, args, { values = {}, heavy = false, label = `compose ${args[0]}`, input } = {}) {
|
|
38
|
+
const bare = local.composeEnv.filter((item) => !Object.hasOwn(values, item.name)).map((item) => item.name);
|
|
39
|
+
// A command that creates or enters a container needs the real values; the others only interpolate the file, where a placeholder does.
|
|
40
|
+
if (bare.length && ['up', 'exec', 'run', 'create', 'start', 'restart'].includes(String(args[0])))
|
|
41
|
+
throw refusal('STACK_LOCAL_COMPOSE_VALUES', `docker compose ${String(args[0])} needs the custody values of ${bare.join(', ')}`);
|
|
42
|
+
const env = { ...ctx.env, ...portEnvironment(local.ports), ...grantEnvironment(ctx, local), STACK_DATA_DIR: dataOf(ctx, local).replaceAll('\\', '/'), ...Object.fromEntries(bare.map((name) => [name, 'placeholder'])), ...values };
|
|
43
|
+
const argv = composeArgs(local, args);
|
|
44
|
+
if (heavy && ctx.heavy)
|
|
45
|
+
return ctx.heavy({ kind: 'build', label: `${local.project} ${label}`, cwd: local.repoRoot, argv: ['docker', ...argv], env });
|
|
46
|
+
return ctx.spawn('docker', argv, { cwd: local.repoRoot, env, ...(input === undefined ? {} : { input }) });
|
|
47
|
+
}
|
|
48
|
+
/** The compose-interpolated values of the environment (the keys `composeEnv` maps), from the opened custody. */
|
|
49
|
+
export function composeValues(local, custody) {
|
|
50
|
+
const values = {};
|
|
51
|
+
for (const item of local.composeEnv) {
|
|
52
|
+
const value = custody?.values.get(item.key);
|
|
53
|
+
if (value !== undefined)
|
|
54
|
+
values[item.name] = value;
|
|
55
|
+
}
|
|
56
|
+
return values;
|
|
57
|
+
}
|
|
58
|
+
/** The environment a command or process runs with: ports expanded, custody as `<KEY>_FILE`, the database URL as the environment declares. */
|
|
59
|
+
export function runEnvironment(ctx, local, run, custody, role) {
|
|
60
|
+
const env = { ...ctx.env, ...portEnvironment(local.ports) };
|
|
61
|
+
for (const [key, value] of Object.entries(run.env))
|
|
62
|
+
env[key] = expand(ctx, local, value);
|
|
63
|
+
if (custody)
|
|
64
|
+
Object.assign(env, fileEnvironment(custody, run.files));
|
|
65
|
+
const url = custody && role ? databaseUrl(local, custody, role) : null;
|
|
66
|
+
if (local.database && url !== null && custody) {
|
|
67
|
+
if (local.database.urlAs === 'env')
|
|
68
|
+
env[local.database.urlEnv] = url;
|
|
69
|
+
else {
|
|
70
|
+
const file = path.join(custody.directory, local.database.urlEnv);
|
|
71
|
+
fs.writeFileSync(file, url, { mode: 0o600 });
|
|
72
|
+
env[`${local.database.urlEnv}_FILE`] = file;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return env;
|
|
76
|
+
}
|
|
77
|
+
/** The loopback connection string of the environment's own database role, built in memory from custody. */
|
|
78
|
+
export function databaseUrl(local, custody, role = 'owner') {
|
|
79
|
+
const database = local.database;
|
|
80
|
+
if (!database)
|
|
81
|
+
return null;
|
|
82
|
+
const app = role === 'app' && database.appRole !== null && database.appPassword !== null;
|
|
83
|
+
const user = app ? database.appRole : database.role;
|
|
84
|
+
const password = custody.values.get(app ? database.appPassword.key : database.password.key);
|
|
85
|
+
if (!password)
|
|
86
|
+
return null;
|
|
87
|
+
return `postgresql://${user}:${encodeURIComponent(password)}@127.0.0.1:${local.ports[database.port]}/${database.name}`;
|
|
88
|
+
}
|
|
89
|
+
const launcher = (ctx, argv) => launch(ctx.platform, argv[0] ?? '', argv.slice(1));
|
|
90
|
+
/** Runs one setup or seed command to its end, through the governor when its class is heavy. A non-zero exit is a failure naming the command and its first error line. */
|
|
91
|
+
export async function runCommand(ctx, local, run, env) {
|
|
92
|
+
const cwd = path.join(local.repoRoot, run.cwd);
|
|
93
|
+
const [command, args] = launcher(ctx, run.argv.map((part) => expand(ctx, local, part)));
|
|
94
|
+
const result = run.heavy !== 'none' && ctx.heavy
|
|
95
|
+
? await ctx.heavy({ kind: run.heavy, label: `${local.project} ${run.name}`, cwd, argv: [command, ...args], env })
|
|
96
|
+
: ctx.spawn(command, args, { cwd, env });
|
|
97
|
+
if (result.error || result.status !== 0)
|
|
98
|
+
throw failure('STACK_LOCAL_COMMAND', `${run.name} (${run.argv.join(' ')}) failed: ${firstLine(result.stderr) || firstLine(result.stdout) || result.error?.message || `exit ${result.status}`}`);
|
|
99
|
+
}
|
|
100
|
+
/** The digest of a command's argv and cwd: the key of its receipt. */
|
|
101
|
+
export const commandDigest = (run) => createHash('sha256').update(JSON.stringify([run.argv, run.cwd, run.env])).digest('hex').slice(0, 16);
|
|
102
|
+
/** True when `pid` is alive. */
|
|
103
|
+
export const isAlive = (pid) => {
|
|
104
|
+
try {
|
|
105
|
+
process.kill(pid, 0);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
return error.code === 'EPERM';
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
/** Starts a native process detached from this command, its output in the environment's log. Returns the pid. */
|
|
113
|
+
export function startProcess(ctx, local, run, env) {
|
|
114
|
+
const dir = stateOf(ctx, local);
|
|
115
|
+
const log = logFile(dir, run.name);
|
|
116
|
+
fs.mkdirSync(path.dirname(log), { recursive: true });
|
|
117
|
+
const descriptor = fs.openSync(log, 'a');
|
|
118
|
+
try {
|
|
119
|
+
const [command, args] = launcher(ctx, run.argv.map((part) => expand(ctx, local, part)));
|
|
120
|
+
const child = ctx.spawn(command, args, { background: true, detached: true, cwd: path.join(local.repoRoot, run.cwd), env, stdio: ['ignore', descriptor, descriptor], windowsHide: true });
|
|
121
|
+
const pid = child.pid;
|
|
122
|
+
if (typeof pid !== 'number')
|
|
123
|
+
throw failure('STACK_LOCAL_COMMAND', `${run.name} did not start: ${child.spawnError?.message ?? 'no process id'}`);
|
|
124
|
+
child.unref?.();
|
|
125
|
+
return pid;
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
fs.closeSync(descriptor);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Ends a native process and its children. */
|
|
132
|
+
export function stopProcess(ctx, pid, deps = {}) {
|
|
133
|
+
if (deps.kill) {
|
|
134
|
+
deps.kill(pid);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (ctx.platform === 'win32') {
|
|
138
|
+
ctx.spawn('taskkill', ['/PID', String(pid), '/T', '/F']);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
process.kill(-pid, 'SIGTERM');
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
try {
|
|
146
|
+
process.kill(pid, 'SIGTERM');
|
|
147
|
+
}
|
|
148
|
+
catch { /* already gone */ }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** One HTTP probe against its expanded URL; the verdict and a detail line. */
|
|
152
|
+
export async function probe(ctx, local, row) {
|
|
153
|
+
const url = expand(ctx, local, row.url);
|
|
154
|
+
try {
|
|
155
|
+
const response = await ctx.fetch(url, { method: row.method, redirect: 'manual', signal: AbortSignal.timeout(5000) });
|
|
156
|
+
if (response.status !== row.status)
|
|
157
|
+
return { ok: false, detail: `${row.name}: ${url} answered ${response.status}, ${row.status} expected` };
|
|
158
|
+
if (row.contains !== null && !(await response.text()).includes(row.contains))
|
|
159
|
+
return { ok: false, detail: `${row.name}: ${url} answered without the expected text` };
|
|
160
|
+
return { ok: true, detail: `${row.name}: ${url} answered ${response.status}` };
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
return { ok: false, detail: `${row.name}: ${url} does not answer (${error.message})` };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Polls a probe until it passes or the deadline passes. */
|
|
167
|
+
export async function waitFor(ctx, deadline, attempt) {
|
|
168
|
+
let last = await attempt();
|
|
169
|
+
while (!last.ok && ctx.now() < deadline) {
|
|
170
|
+
await ctx.sleep(1000);
|
|
171
|
+
last = await attempt();
|
|
172
|
+
}
|
|
173
|
+
return last;
|
|
174
|
+
}
|
|
175
|
+
/** The per-service readiness the environment declares: compose health, an open port, or just started. */
|
|
176
|
+
export async function serviceReady(ctx, local, name, deadline, deps = {}) {
|
|
177
|
+
const service = local.services.find((item) => item.name === name);
|
|
178
|
+
if (!service)
|
|
179
|
+
return { ok: false, detail: `${name} is not a declared service` };
|
|
180
|
+
return waitFor(ctx, deadline, async () => {
|
|
181
|
+
const ids = await compose(ctx, local, ['ps', '-q', name]);
|
|
182
|
+
const id = firstLine(ids.stdout);
|
|
183
|
+
if (!id)
|
|
184
|
+
return { ok: false, detail: `${name}: no container` };
|
|
185
|
+
if (service.wait === 'tcp')
|
|
186
|
+
return (await (deps.tcpOpen ?? tcpOpen)(local.ports[service.port])) ? { ok: true, detail: `${name}: port ${local.ports[service.port]} accepts` } : { ok: false, detail: `${name}: port ${local.ports[service.port]} does not accept yet` };
|
|
187
|
+
const inspected = ctx.spawn('docker', ['inspect', '-f', '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{else}}nohealth{{end}}', id]);
|
|
188
|
+
const [state, health] = firstLine(inspected.stdout).split(' ');
|
|
189
|
+
if (state !== 'running')
|
|
190
|
+
return { ok: false, detail: `${name}: ${state ?? 'unknown'}` };
|
|
191
|
+
if (service.wait === 'started')
|
|
192
|
+
return { ok: true, detail: `${name}: running` };
|
|
193
|
+
if (health === 'nohealth')
|
|
194
|
+
throw refusal('STACK_LOCAL_NO_HEALTHCHECK', `${name} declares wait: healthy but its compose service has no healthcheck: add one to the compose file, or declare wait: tcp or started`);
|
|
195
|
+
return health === 'healthy' ? { ok: true, detail: `${name}: healthy` } : { ok: false, detail: `${name}: ${health}` };
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
/** Masks the value of anything that reads like a credential assignment, so a log tail never carries one. */
|
|
199
|
+
export const redact = (line) => line.replaceAll(/((?:pass(?:word)?|secret|token|credential|private[_-]?key|api[_-]?key|authorization)[\w-]*["']?\s*[=:]\s*(?:Bearer\s+)?)[^\s"',;]+/giu, '$1[redacted]');
|
|
200
|
+
export { hostTool };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** What the environment recorded about itself. */
|
|
2
|
+
export interface LocalRecord {
|
|
3
|
+
processes: Record<string, {
|
|
4
|
+
pid: number;
|
|
5
|
+
startedAt: string;
|
|
6
|
+
port: number | null;
|
|
7
|
+
}>;
|
|
8
|
+
ports: Record<string, number>;
|
|
9
|
+
receipts: {
|
|
10
|
+
migrate?: {
|
|
11
|
+
at: string;
|
|
12
|
+
digest: string;
|
|
13
|
+
};
|
|
14
|
+
seed: Record<string, {
|
|
15
|
+
at: string;
|
|
16
|
+
digest: string;
|
|
17
|
+
}>;
|
|
18
|
+
setup: Record<string, {
|
|
19
|
+
at: string;
|
|
20
|
+
digest: string;
|
|
21
|
+
}>;
|
|
22
|
+
database?: {
|
|
23
|
+
at: string;
|
|
24
|
+
role: string;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** The directory of one environment's state: `<state base>/stack/<project>/<env>`. */
|
|
29
|
+
export declare const stateDirectory: (env: NodeJS.ProcessEnv, project: string, environment: string) => string;
|
|
30
|
+
/** The log file of one process or service. */
|
|
31
|
+
export declare const logFile: (dir: string, name: string) => string;
|
|
32
|
+
/** Reads the record; an absent or damaged file is an empty record (the environment then reads as not started). */
|
|
33
|
+
export declare function readRecord(dir: string): LocalRecord;
|
|
34
|
+
/** Writes the record atomically; it holds ids, ports and digests, never a value. */
|
|
35
|
+
export declare function writeRecord(dir: string, record: LocalRecord): void;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// local-state.mjs - where a dev environment keeps what it started: process ids, logs and receipts, under the per-host state base
|
|
2
|
+
// (STARCI_LOCAL_ROOT, else the runtime's .runtime), never in the app's checkout (README "Harness state lives in the runtime").
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { starciLocalRoot } from '../runtime/engine/runtime-root.mjs';
|
|
6
|
+
const empty = () => ({ processes: {}, ports: {}, receipts: { seed: {}, setup: {} } });
|
|
7
|
+
/** The directory of one environment's state: `<state base>/stack/<project>/<env>`. */
|
|
8
|
+
export const stateDirectory = (env, project, environment) => path.join(starciLocalRoot(env), 'stack', project, environment);
|
|
9
|
+
/** The log file of one process or service. */
|
|
10
|
+
export const logFile = (dir, name) => path.join(dir, 'logs', `${name}.log`);
|
|
11
|
+
/** Reads the record; an absent or damaged file is an empty record (the environment then reads as not started). */
|
|
12
|
+
export function readRecord(dir) {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
|
|
15
|
+
const base = empty();
|
|
16
|
+
return { processes: parsed.processes ?? base.processes, ports: parsed.ports ?? base.ports, receipts: { ...base.receipts, ...parsed.receipts } };
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return empty();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Writes the record atomically; it holds ids, ports and digests, never a value. */
|
|
23
|
+
export function writeRecord(dir, record) {
|
|
24
|
+
fs.mkdirSync(path.join(dir, 'logs'), { recursive: true });
|
|
25
|
+
const target = path.join(dir, 'state.json');
|
|
26
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
27
|
+
fs.writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`);
|
|
28
|
+
fs.renameSync(temporary, target);
|
|
29
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { StackContext } from './context.mjs';
|
|
2
|
+
import type { ActionResult } from './remote.mjs';
|
|
3
|
+
import type { LocalConfig } from './local-config.mjs';
|
|
4
|
+
import type { LocalDeps } from './local-run.mjs';
|
|
5
|
+
/** `status` for a dev environment. */
|
|
6
|
+
export declare function statusLocal(ctx: StackContext, local: LocalConfig, _options?: Record<string, unknown>, deps?: LocalDeps): Promise<ActionResult>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { firstLine } from './context.mjs';
|
|
2
|
+
import { migrationsDigest } from './local-up.mjs';
|
|
3
|
+
import { compose, isAlive, probe, stateOf } from './local-run.mjs';
|
|
4
|
+
import { readRecord } from './local-state.mjs';
|
|
5
|
+
/** `status` for a dev environment. */
|
|
6
|
+
export async function statusLocal(ctx, local, _options = {}, deps = {}) {
|
|
7
|
+
const record = readRecord(stateOf(ctx, local));
|
|
8
|
+
const lines = [];
|
|
9
|
+
let ok = true;
|
|
10
|
+
const mark = (good, text) => { ok &&= good; lines.push(`${good ? 'ok ' : 'FAIL '}${text}`); };
|
|
11
|
+
for (const service of local.services) {
|
|
12
|
+
const id = firstLine((await compose(ctx, local, ['ps', '-q', service.name])).stdout);
|
|
13
|
+
if (!id) {
|
|
14
|
+
mark(false, `service ${service.name}: not created`);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
const inspected = ctx.spawn('docker', ['inspect', '-f', '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{else}}nohealth{{end}}', id]);
|
|
18
|
+
const [state, health] = firstLine(inspected.stdout).split(' ');
|
|
19
|
+
mark(state === 'running' && (service.wait !== 'healthy' || health === 'healthy'), `service ${service.name}: ${state}${health === 'nohealth' ? '' : `, ${health}`}`);
|
|
20
|
+
}
|
|
21
|
+
const alive = deps.alive ?? isAlive;
|
|
22
|
+
for (const run of local.processes) {
|
|
23
|
+
const entry = record.processes[run.name];
|
|
24
|
+
mark(entry !== undefined && alive(entry.pid), entry ? `process ${run.name}: pid ${entry.pid} ${alive(entry.pid) ? 'alive' : 'gone'}` : `process ${run.name}: not started`);
|
|
25
|
+
}
|
|
26
|
+
if (local.database) {
|
|
27
|
+
const receipt = record.receipts.migrate;
|
|
28
|
+
const current = migrationsDigest(local.repoRoot);
|
|
29
|
+
mark(receipt?.digest === current, receipt ? `migrations: receipt ${receipt.digest} ${receipt.digest === current ? 'is current' : `is stale, the folder is ${current}`}` : 'migrations: no receipt');
|
|
30
|
+
}
|
|
31
|
+
for (const row of local.ready) {
|
|
32
|
+
const result = await probe(ctx, local, row);
|
|
33
|
+
mark(result.ok, `ready ${result.detail}`);
|
|
34
|
+
}
|
|
35
|
+
return { ok, lines, data: { processes: record.processes } };
|
|
36
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { StackContext } from './context.mjs';
|
|
2
|
+
import type { ActionResult } from './remote.mjs';
|
|
3
|
+
import type { LocalConfig } from './local-config.mjs';
|
|
4
|
+
import type { LocalDeps } from './local-run.mjs';
|
|
5
|
+
/** The digest of the migration folder (names and contents): what a migrate receipt records. */
|
|
6
|
+
export declare function migrationsDigest(repoRoot: string): string;
|
|
7
|
+
/** `up` for a dev environment. */
|
|
8
|
+
export declare function upLocal(ctx: StackContext, local: LocalConfig, { execute, timeoutSeconds }?: {
|
|
9
|
+
execute?: boolean;
|
|
10
|
+
timeoutSeconds?: number;
|
|
11
|
+
}, deps?: LocalDeps): Promise<ActionResult>;
|