@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,89 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
// local-cli.mjs - the dev actions of `starci app stack` (PROJECT-STACKS-6): doctor, up, down and logs, and prepare, status and verify when the
|
|
10
|
+
// named environment runs docker-compose. Each takes only its own flags; effects (up, down, prepare) are dry runs until --execute.
|
|
11
|
+
// starci app stack doctor --env <env>
|
|
12
|
+
// starci app stack prepare --env <env> [--execute]
|
|
13
|
+
// starci app stack up --env <env> [--execute] [--timeout <seconds>]
|
|
14
|
+
// starci app stack status --env <env>
|
|
15
|
+
// starci app stack logs --env <env> --service <name> [--lines <n>]
|
|
16
|
+
// starci app stack verify --env <env>
|
|
17
|
+
// starci app stack down --env <env> [--execute]
|
|
18
|
+
import { loadLocalConfig, isLocalEnvironment } from './local-config.mjs';
|
|
19
|
+
import { EXIT, refusal } from './errors.mjs';
|
|
20
|
+
/** The actions that exist only for a dev environment. */
|
|
21
|
+
export const LOCAL_ONLY = Object.freeze(['doctor', 'up', 'down', 'logs']);
|
|
22
|
+
/** The actions a swarm environment already has, which a docker-compose environment answers with its dev implementation. */
|
|
23
|
+
export const LOCAL_SHARED = Object.freeze(['prepare', 'status', 'verify']);
|
|
24
|
+
/** True when `value` is an action only a dev environment has. */
|
|
25
|
+
export const isLocalOnly = (value) => LOCAL_ONLY.includes(value);
|
|
26
|
+
/** True when `value` is an action both kinds have. */
|
|
27
|
+
export const isLocalShared = (value) => LOCAL_SHARED.includes(value);
|
|
28
|
+
const FLAGS = Object.freeze({
|
|
29
|
+
doctor: [], up: ['--execute', '--timeout'], down: ['--execute'], logs: ['--service', '--lines'], prepare: ['--execute'], status: [], verify: [],
|
|
30
|
+
});
|
|
31
|
+
const VALUES = new Set(['--env', '--timeout', '--service', '--lines']);
|
|
32
|
+
/** Parses the flags of one dev action; a flag of another action, an unknown flag and a missing value are refusals. */
|
|
33
|
+
export function parseLocalFlags(action, argv) {
|
|
34
|
+
const own = new Set(['--env', '--json', ...(FLAGS[action] ?? [])]);
|
|
35
|
+
const flags = { json: false, execute: false };
|
|
36
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
37
|
+
const arg = argv[index];
|
|
38
|
+
if (!arg.startsWith('--'))
|
|
39
|
+
throw refusal('STACK_USAGE', `stack ${action} takes no operand (got ${arg})`);
|
|
40
|
+
if (!own.has(arg))
|
|
41
|
+
throw refusal('STACK_USAGE', `stack ${action} takes no ${arg}: its flags are ${[...own].join(', ')}`);
|
|
42
|
+
if (arg === '--json')
|
|
43
|
+
flags.json = true;
|
|
44
|
+
else if (arg === '--execute')
|
|
45
|
+
flags.execute = true;
|
|
46
|
+
else if (VALUES.has(arg)) {
|
|
47
|
+
const value = argv[index + 1];
|
|
48
|
+
if (value === undefined || value.startsWith('--'))
|
|
49
|
+
throw refusal('STACK_USAGE', `${arg} needs a value`);
|
|
50
|
+
index += 1;
|
|
51
|
+
flags[arg.slice(2)] = value;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (flags.env === undefined)
|
|
55
|
+
throw refusal('STACK_USAGE', `stack ${action} names its environment with --env <env>`);
|
|
56
|
+
return flags;
|
|
57
|
+
}
|
|
58
|
+
const whole = (value, name, fallback) => {
|
|
59
|
+
if (value === undefined)
|
|
60
|
+
return fallback;
|
|
61
|
+
const parsed = Number(value);
|
|
62
|
+
if (!Number.isInteger(parsed) || parsed < 1)
|
|
63
|
+
throw refusal('STACK_USAGE', `${name} must be a whole number`);
|
|
64
|
+
return parsed;
|
|
65
|
+
};
|
|
66
|
+
/** The module of each dev action. */
|
|
67
|
+
const MODULES = {
|
|
68
|
+
doctor: ['./local-doctor.mjs', 'doctorLocal'], prepare: ['./local-prepare.mjs', 'prepareLocal'], up: ['./local-up.mjs', 'upLocal'], status: ['./local-status.mjs', 'statusLocal'],
|
|
69
|
+
logs: ['./local-logs.mjs', 'logsLocal'], verify: ['./local-verify.mjs', 'verifyLocal'], down: ['./local-down.mjs', 'downLocal'],
|
|
70
|
+
};
|
|
71
|
+
/** Runs one dev action and returns the exit code. */
|
|
72
|
+
export async function runLocal(ctx, action, argv, deps = {}) {
|
|
73
|
+
const flags = parseLocalFlags(action, argv);
|
|
74
|
+
const local = loadLocalConfig({ repoRoot: ctx.cwd, env: flags.env });
|
|
75
|
+
const [file, name] = MODULES[action];
|
|
76
|
+
const handler = (await import(__rewriteRelativeImportExtension(file)))[name];
|
|
77
|
+
const result = await handler(ctx, local, { execute: flags.execute, timeoutSeconds: whole(flags.timeout, '--timeout', 180), service: flags.service, lines: whole(flags.lines, '--lines', 200) }, deps);
|
|
78
|
+
if (flags.json)
|
|
79
|
+
ctx.stdout(`${JSON.stringify({ verb: action, ok: result.ok, ...result.data }, null, 2)}\n`);
|
|
80
|
+
else
|
|
81
|
+
for (const line of result.lines)
|
|
82
|
+
ctx.stdout(`${line}\n`);
|
|
83
|
+
if (result.refused) {
|
|
84
|
+
ctx.stderr(`starci app stack: ${result.refused.code}: ${result.refused.message}\n`);
|
|
85
|
+
return EXIT.refused;
|
|
86
|
+
}
|
|
87
|
+
return result.ok ? EXIT.ok : EXIT.failed;
|
|
88
|
+
}
|
|
89
|
+
export { isLocalEnvironment };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { LocalBlock } from '../runtime/scripts/hfs/stack-local-config.mjs';
|
|
2
|
+
/** The dev configuration of one environment, with absolute paths. */
|
|
3
|
+
export interface LocalConfig extends LocalBlock {
|
|
4
|
+
env: string;
|
|
5
|
+
appProject: string;
|
|
6
|
+
repoRoot: string;
|
|
7
|
+
envDir: string;
|
|
8
|
+
composeFiles: string[];
|
|
9
|
+
secretsDir: string;
|
|
10
|
+
}
|
|
11
|
+
/** True when the environment is declared and runs docker-compose (the dev kind); false for a swarm or an unknown one. */
|
|
12
|
+
export declare function isLocalEnvironment(repoRoot: string, env: string): boolean;
|
|
13
|
+
/** The normalised dev configuration; a refusal names every problem when the block is missing or invalid. */
|
|
14
|
+
export declare function loadLocalConfig({ repoRoot, env }: {
|
|
15
|
+
repoRoot: string;
|
|
16
|
+
env: string;
|
|
17
|
+
}): LocalConfig;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// local-config.mjs - loads `environments.<env>.local` of .starcistacks/application-stacks.yaml for the dev verbs of `starci app stack`.
|
|
2
|
+
// The block is read and judged by the module `starci app check` uses (runtime/scripts/hfs/stack-local-config.mjs).
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { normalizeLocal, LOCAL_CONFIG_CODE } from '../runtime/scripts/hfs/stack-local-config.mjs';
|
|
6
|
+
import { readYaml, STACKS_DIR, STACKS_FILE } from './config.mjs';
|
|
7
|
+
import { refusal } from './errors.mjs';
|
|
8
|
+
/** True when the environment is declared and runs docker-compose (the dev kind); false for a swarm or an unknown one. */
|
|
9
|
+
export function isLocalEnvironment(repoRoot, env) {
|
|
10
|
+
try {
|
|
11
|
+
const declaration = readYaml(path.join(repoRoot, STACKS_DIR, STACKS_FILE), STACKS_FILE);
|
|
12
|
+
return declaration?.environments?.[env]?.runtime === 'docker-compose';
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** The normalised dev configuration; a refusal names every problem when the block is missing or invalid. */
|
|
19
|
+
export function loadLocalConfig({ repoRoot, env }) {
|
|
20
|
+
const declaration = readYaml(path.join(repoRoot, STACKS_DIR, STACKS_FILE), STACKS_FILE);
|
|
21
|
+
const environment = declaration?.environments?.[env];
|
|
22
|
+
if (!environment)
|
|
23
|
+
throw refusal('STACKS_DEPLOY_ENV_UNKNOWN', `${STACKS_FILE} declares no environment ${env}`);
|
|
24
|
+
if (environment.runtime !== 'docker-compose')
|
|
25
|
+
throw refusal('STACK_LOCAL_NOT_DEV', `environment ${env} runs ${environment.runtime}; up, down, doctor and logs run a docker-compose dev environment`);
|
|
26
|
+
if (environment.local === undefined)
|
|
27
|
+
throw refusal('STACK_LOCAL_UNDECLARED', `environment ${env} has no local block in ${STACKS_FILE}: declare the ports, services, processes and probes the dev environment runs (PROJECT-STACKS-6)`);
|
|
28
|
+
const appProject = String(JSON.parse(fs.readFileSync(path.join(repoRoot, 'hfs.json'), 'utf8')).project);
|
|
29
|
+
const { config, problems } = normalizeLocal(environment.local, { project: appProject, env });
|
|
30
|
+
if (!config) {
|
|
31
|
+
const first = problems.slice(0, 6).map((item) => `${item.at}: ${item.message}`).join('; ');
|
|
32
|
+
throw refusal(LOCAL_CONFIG_CODE, `${problems.length} problem${problems.length === 1 ? '' : 's'} in environments.${env}.local: ${first}`);
|
|
33
|
+
}
|
|
34
|
+
const envDir = path.join(repoRoot, STACKS_DIR, env);
|
|
35
|
+
const composeFiles = (environment.composeFiles ?? []).map((file) => path.join(envDir, file));
|
|
36
|
+
return { ...config, env, appProject, repoRoot, envDir, composeFiles, secretsDir: path.join(envDir, 'secrets') };
|
|
37
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { StackContext } from './context.mjs';
|
|
2
|
+
import type { LocalConfig } from './local-config.mjs';
|
|
3
|
+
/** The opened custody: the directory of the host-local `<KEY>` files and the values, in memory only. */
|
|
4
|
+
export interface Custody {
|
|
5
|
+
directory: string;
|
|
6
|
+
values: Map<string, string>;
|
|
7
|
+
}
|
|
8
|
+
/** Every key the environment's commands, processes, database and compose read. */
|
|
9
|
+
export declare function neededKeys(local: LocalConfig): string[];
|
|
10
|
+
/** Which sealed member holds each key, read from the cleartext key names (no identity needed). */
|
|
11
|
+
export declare function holders(secretsDir: string): Map<string, string>;
|
|
12
|
+
/** The keys needed that no sealed member holds. */
|
|
13
|
+
export declare const missingKeys: (local: LocalConfig) => string[];
|
|
14
|
+
/** The owner step for keys no member holds: init when the environment generates them, else set. */
|
|
15
|
+
export declare function missingStep(local: LocalConfig, missing: readonly string[]): string;
|
|
16
|
+
/**
|
|
17
|
+
* Decrypts the needed keys in memory and writes each to its own private file outside the checkout. A key no member holds, or a member the
|
|
18
|
+
* machine's identity cannot open, is a refusal that names the owner step; nothing is generated here.
|
|
19
|
+
*/
|
|
20
|
+
export declare function openCustody(ctx: StackContext, local: LocalConfig): Custody;
|
|
21
|
+
/** The directory of one least grant inside the host-local custody directory (created private; it holds only the keys the grant names). */
|
|
22
|
+
export declare function grantDirectory(directory: string, name: string): string;
|
|
23
|
+
/** The `<KEY>_FILE` environment of the keys a command reads: each points at the host-local file. */
|
|
24
|
+
export declare const fileEnvironment: (custody: Custody, keys: readonly string[]) => Record<string, string>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// local-custody.mjs - the dev custody of a dev environment: which keys the environment reads, whether they are sealed, and their host-local
|
|
2
|
+
// files (PROJECT-STACKS-5). Opening a member needs the owner identity; a machine without it is refused with the exact owner step, and a missing
|
|
3
|
+
// value is never answered by minting a replacement (only the owner-armed `starci app secret init --env <env>` creates dev values, once).
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { envelopeOf } from '../runtime/scripts/hfs/secret-document.mjs';
|
|
7
|
+
import { materializedDirectory, writeSecretFile } from '../runtime/scripts/hfs/secret-files.mjs';
|
|
8
|
+
import { isSealedName, memberOfName } from '../runtime/scripts/lib/sealed-member.mjs';
|
|
9
|
+
import { refusal } from './errors.mjs';
|
|
10
|
+
/** Every key the environment's commands, processes, database and compose read. */
|
|
11
|
+
export function neededKeys(local) {
|
|
12
|
+
const keys = new Set();
|
|
13
|
+
for (const run of [...local.setup, ...local.seed, ...local.processes])
|
|
14
|
+
for (const key of run.files)
|
|
15
|
+
if (key !== local.database?.urlEnv)
|
|
16
|
+
keys.add(key);
|
|
17
|
+
for (const item of local.composeEnv)
|
|
18
|
+
keys.add(item.key);
|
|
19
|
+
for (const grant of local.grants)
|
|
20
|
+
for (const key of grant.keys)
|
|
21
|
+
keys.add(key);
|
|
22
|
+
if (local.database) {
|
|
23
|
+
keys.add(local.database.password.key);
|
|
24
|
+
keys.add(local.database.adminPassword.key);
|
|
25
|
+
if (local.database.appPassword)
|
|
26
|
+
keys.add(local.database.appPassword.key);
|
|
27
|
+
}
|
|
28
|
+
return [...keys].sort();
|
|
29
|
+
}
|
|
30
|
+
/** Which sealed member holds each key, read from the cleartext key names (no identity needed). */
|
|
31
|
+
export function holders(secretsDir) {
|
|
32
|
+
const held = new Map();
|
|
33
|
+
let names = [];
|
|
34
|
+
try {
|
|
35
|
+
names = fs.readdirSync(secretsDir).filter(isSealedName).sort();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return held;
|
|
39
|
+
}
|
|
40
|
+
for (const name of names) {
|
|
41
|
+
try {
|
|
42
|
+
for (const key of envelopeOf(fs.readFileSync(path.join(secretsDir, name), 'utf8')).keys)
|
|
43
|
+
if (!held.has(key))
|
|
44
|
+
held.set(key, name);
|
|
45
|
+
}
|
|
46
|
+
catch { /* an unreadable member is judged when it is opened */ }
|
|
47
|
+
}
|
|
48
|
+
return held;
|
|
49
|
+
}
|
|
50
|
+
/** The keys needed that no sealed member holds. */
|
|
51
|
+
export const missingKeys = (local) => {
|
|
52
|
+
const held = holders(local.secretsDir);
|
|
53
|
+
return neededKeys(local).filter((key) => !held.has(key));
|
|
54
|
+
};
|
|
55
|
+
/** The owner step for keys no member holds: init when the environment generates them, else set. */
|
|
56
|
+
export function missingStep(local, missing) {
|
|
57
|
+
const generated = new Set(local.custody.flatMap((item) => [item.key, ...(item.publicKey ? [item.publicKey] : [])]));
|
|
58
|
+
const create = missing.filter((key) => generated.has(key));
|
|
59
|
+
const set = missing.filter((key) => !generated.has(key));
|
|
60
|
+
return [
|
|
61
|
+
create.length ? `the owner arms "starci app secret init --env ${local.env} --execute" to create ${create.join(', ')} once` : '',
|
|
62
|
+
set.length ? `the owner seals ${set.join(', ')} with "starci app secret set --env ${local.env} <slug> <KEY>"` : '',
|
|
63
|
+
].filter(Boolean).join('; ');
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Decrypts the needed keys in memory and writes each to its own private file outside the checkout. A key no member holds, or a member the
|
|
67
|
+
* machine's identity cannot open, is a refusal that names the owner step; nothing is generated here.
|
|
68
|
+
*/
|
|
69
|
+
export function openCustody(ctx, local) {
|
|
70
|
+
const needed = neededKeys(local);
|
|
71
|
+
const held = holders(local.secretsDir);
|
|
72
|
+
const missing = needed.filter((key) => !held.has(key));
|
|
73
|
+
if (missing.length)
|
|
74
|
+
throw refusal('STACK_LOCAL_CUSTODY_MISSING', `no sealed member of ${local.env} holds ${missing.join(', ')}: ${missingStep(local, missing)}`);
|
|
75
|
+
const values = new Map();
|
|
76
|
+
const members = [...new Set(needed.map((key) => held.get(key)))];
|
|
77
|
+
for (const name of members) {
|
|
78
|
+
const file = path.join(local.secretsDir, name);
|
|
79
|
+
try {
|
|
80
|
+
ctx.sops.requireIdentity?.(file);
|
|
81
|
+
const map = ctx.sops.decrypt(file, envelopeOf(fs.readFileSync(file, 'utf8')).format);
|
|
82
|
+
for (const key of needed)
|
|
83
|
+
if (held.get(key) === name && typeof map[key] === 'string')
|
|
84
|
+
values.set(key, map[key]);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
const why = String(error.message ?? error).split(/\r?\n/u)[0];
|
|
88
|
+
throw refusal('STACK_LOCAL_IDENTITY_MISSING', `${memberOfName(name)} (${local.env}) cannot be opened on this machine: ${why}. The owner step: put the age identity that ${local.env} is sealed to in SOPS_AGE_KEY of the harness secret.env (or .claude/secret.env of the app), obtained from the owner; a replacement is never generated for an existing sealed value (PROJECT-STACKS-5)`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const directory = materializedDirectory(local.repoRoot, local.env, ctx.env, ctx.platform);
|
|
92
|
+
for (const [key, value] of values)
|
|
93
|
+
writeSecretFile(directory, key, value);
|
|
94
|
+
for (const grant of local.grants) {
|
|
95
|
+
const target = grantDirectory(directory, grant.name);
|
|
96
|
+
for (const key of grant.keys)
|
|
97
|
+
if (values.has(key))
|
|
98
|
+
writeSecretFile(target, key, values.get(key));
|
|
99
|
+
}
|
|
100
|
+
return { directory, values };
|
|
101
|
+
}
|
|
102
|
+
/** The directory of one least grant inside the host-local custody directory (created private; it holds only the keys the grant names). */
|
|
103
|
+
export function grantDirectory(directory, name) {
|
|
104
|
+
const target = path.join(directory, 'grants', name);
|
|
105
|
+
fs.mkdirSync(target, { recursive: true, mode: 0o700 });
|
|
106
|
+
return target;
|
|
107
|
+
}
|
|
108
|
+
/** The `<KEY>_FILE` environment of the keys a command reads: each points at the host-local file. */
|
|
109
|
+
export const fileEnvironment = (custody, keys) => Object.fromEntries(keys.filter((key) => custody.values.has(key)).map((key) => [`${key}_FILE`, path.join(custody.directory, key)]));
|
|
@@ -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
|
+
/** `doctor` for a dev environment. */
|
|
6
|
+
export declare function doctorLocal(ctx: StackContext, local: LocalConfig, _options?: Record<string, unknown>, deps?: LocalDeps): Promise<ActionResult>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// local-doctor.mjs - `starci app stack doctor --env <dev env>` (PROJECT-STACKS-6): the read-only inspection of a dev environment and of the machine
|
|
2
|
+
// it runs on. The prerequisite table (tools, engine, resources, ports) names the owner-run install step per OS for each missing one; then
|
|
3
|
+
// the custody (which keys are sealed, whether this machine's identity opens the members), the compose render, the volumes and the recorded
|
|
4
|
+
// processes. It changes nothing and never prints a value.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { firstLine, lines as splitLines } from './context.mjs';
|
|
8
|
+
import { holders, missingKeys, missingStep, neededKeys } from './local-custody.mjs';
|
|
9
|
+
import { tableLines } from './local-host.mjs';
|
|
10
|
+
import { preflight } from './local-prepare.mjs';
|
|
11
|
+
import { compose, isAlive, stateOf } from './local-run.mjs';
|
|
12
|
+
import { readRecord } from './local-state.mjs';
|
|
13
|
+
function custodyRows(ctx, local) {
|
|
14
|
+
const needed = neededKeys(local);
|
|
15
|
+
const missing = missingKeys(local);
|
|
16
|
+
const rows = [{ id: 'custody', ok: missing.length === 0, detail: missing.length ? `${missing.join(', ')} sealed in no member of ${local.env}` : `${needed.length} key(s) sealed`, fix: missing.length ? missingStep(local, missing) : null }];
|
|
17
|
+
const held = holders(local.secretsDir);
|
|
18
|
+
const members = [...new Set(needed.map((key) => held.get(key)).filter((name) => name !== undefined))];
|
|
19
|
+
for (const name of members) {
|
|
20
|
+
try {
|
|
21
|
+
ctx.sops.requireIdentity?.(path.join(local.secretsDir, name));
|
|
22
|
+
rows.push({ id: `identity ${name}`, ok: true, detail: 'this machine holds an identity the member is sealed to', fix: null });
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
rows.push({ id: `identity ${name}`, ok: false, detail: String(error.message).split(/\r?\n/u)[0] ?? 'no identity', fix: 'obtain the age identity of the environment from the owner into SOPS_AGE_KEY of the harness secret.env; a replacement is never generated for an existing sealed value' });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return rows;
|
|
29
|
+
}
|
|
30
|
+
/** `doctor` for a dev environment. */
|
|
31
|
+
export async function doctorLocal(ctx, local, _options = {}, deps = {}) {
|
|
32
|
+
const host = await preflight(ctx, local, deps);
|
|
33
|
+
const rows = [...custodyRows(ctx, local)];
|
|
34
|
+
const lines = [...host.lines, ...tableLines(rows)];
|
|
35
|
+
const dockerOk = host.rows.filter((row) => row.id === 'docker' || row.id === 'compose').every((row) => row.ok);
|
|
36
|
+
if (dockerOk) {
|
|
37
|
+
const placeholders = Object.fromEntries(local.composeEnv.map((item) => [item.name, 'placeholder']));
|
|
38
|
+
const render = await compose(ctx, local, ['config', '-q'], { values: placeholders });
|
|
39
|
+
rows.push({ id: 'compose files', ok: render.status === 0, detail: render.status === 0 ? `${local.composeFiles.length} file(s) render` : firstLine(render.stderr) || 'do not render', fix: render.status === 0 ? null : 'fix the compose files named above' });
|
|
40
|
+
lines.push(...tableLines(rows.slice(-1)));
|
|
41
|
+
const volumes = splitLines(ctx.spawn('docker', ['volume', 'ls', '--filter', `label=com.docker.compose.project=${local.project}`, '--format', '{{.Name}}']).stdout);
|
|
42
|
+
lines.push(`volumes: ${volumes.join(', ') || 'none yet'}`);
|
|
43
|
+
}
|
|
44
|
+
const record = readRecord(stateOf(ctx, local));
|
|
45
|
+
const alive = deps.alive ?? isAlive;
|
|
46
|
+
const recorded = Object.entries(record.processes).map(([name, entry]) => `${name} pid ${entry.pid} ${alive(entry.pid) ? 'alive' : 'gone'}`);
|
|
47
|
+
lines.push(`processes: ${recorded.join(', ') || 'none recorded'}`, `state: ${fs.existsSync(stateOf(ctx, local)) ? stateOf(ctx, local) : 'none yet'}`);
|
|
48
|
+
return { ok: host.ok && rows.every((row) => row.ok), lines, data: { rows: [...host.rows, ...rows] } };
|
|
49
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
/** `down` for a dev environment. */
|
|
6
|
+
export declare function downLocal(ctx: StackContext, local: LocalConfig, { execute }?: {
|
|
7
|
+
execute?: boolean;
|
|
8
|
+
}, deps?: LocalDeps): Promise<ActionResult>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { compose, isAlive, stateOf, stopProcess } from './local-run.mjs';
|
|
2
|
+
import { readRecord, writeRecord } from './local-state.mjs';
|
|
3
|
+
/** `down` for a dev environment. */
|
|
4
|
+
export async function downLocal(ctx, local, { execute = false } = {}, deps = {}) {
|
|
5
|
+
const dir = stateOf(ctx, local);
|
|
6
|
+
const record = readRecord(dir);
|
|
7
|
+
const alive = deps.alive ?? isAlive;
|
|
8
|
+
const running = Object.entries(record.processes).filter(([, entry]) => alive(entry.pid));
|
|
9
|
+
const lines = [`processes to end: ${running.map(([name, entry]) => `${name} (pid ${entry.pid})`).join(', ') || 'none'}`, `compose project to stop: ${local.project} (volumes and images stay)`];
|
|
10
|
+
if (!execute)
|
|
11
|
+
return { ok: true, lines: [...lines, 'dry run: nothing was stopped; arm it with --execute'], data: { processes: running.map(([name]) => name) } };
|
|
12
|
+
for (const [name, entry] of running) {
|
|
13
|
+
stopProcess(ctx, entry.pid, deps);
|
|
14
|
+
lines.push(`process ${name}: ended`);
|
|
15
|
+
}
|
|
16
|
+
writeRecord(dir, { ...record, processes: {} });
|
|
17
|
+
const stopped = await compose(ctx, local, ['down'], { label: 'compose down' });
|
|
18
|
+
lines.push(stopped.status === 0 ? `compose ${local.project}: stopped, volumes kept` : `compose ${local.project}: down failed (${(stopped.stderr.trim().split(/\r?\n/u)[0] ?? '').slice(0, 160)})`);
|
|
19
|
+
return { ok: stopped.status === 0, lines, data: { processes: running.map(([name]) => name) } };
|
|
20
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { RunResult, StackContext } from './context.mjs';
|
|
2
|
+
import type { LocalBlock } from '../runtime/scripts/hfs/stack-local-config.mjs';
|
|
3
|
+
/** One row of the prerequisite table. */
|
|
4
|
+
export interface Prerequisite {
|
|
5
|
+
id: string;
|
|
6
|
+
ok: boolean;
|
|
7
|
+
detail: string;
|
|
8
|
+
fix: string | null;
|
|
9
|
+
}
|
|
10
|
+
/** The seams of the host probes a spec replaces. */
|
|
11
|
+
export interface HostDeps {
|
|
12
|
+
portFree?: ((port: number) => Promise<boolean>) | undefined;
|
|
13
|
+
diskFreeGb?: ((dir: string) => number | null) | undefined;
|
|
14
|
+
memoryMb?: (() => {
|
|
15
|
+
free: number;
|
|
16
|
+
total: number;
|
|
17
|
+
}) | undefined;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* How to start `command` on this platform without a shell. npm and npx are `.cmd` shims on Windows, which cannot be spawned directly: they run as
|
|
21
|
+
* `node <npm-cli.js>` from the Node install beside this node when it is there, else through `cmd.exe /d /s /c`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function launch(platform: NodeJS.Platform, command: string, args: readonly string[], exists?: (file: string) => boolean): [string, string[]];
|
|
24
|
+
/** Runs a host tool without a shell (see launch). */
|
|
25
|
+
export declare function hostTool(ctx: Pick<StackContext, 'spawn' | 'platform'>, command: string, args: readonly string[], options?: {
|
|
26
|
+
cwd?: string;
|
|
27
|
+
env?: NodeJS.ProcessEnv;
|
|
28
|
+
}): RunResult;
|
|
29
|
+
/** The Node version the app asks for: `.nvmrc`, else the first version of `engines.node`; null when it asks for none. */
|
|
30
|
+
export declare function wantedNode(repoRoot: string): string | null;
|
|
31
|
+
/** True when the environment's secrets folder holds a sealed member. */
|
|
32
|
+
export declare const hasSealedMembers: (secretsDir: string) => boolean;
|
|
33
|
+
/** One row per declared port: free, or held by this environment's own record, or held by someone else (named). */
|
|
34
|
+
export declare function portRows(ctx: StackContext, local: LocalBlock, stateDir: string, deps?: HostDeps): Promise<Prerequisite[]>;
|
|
35
|
+
/** The whole table for one environment: tools, engine, resources, then the declared ports. */
|
|
36
|
+
export declare function hostPrerequisites(ctx: StackContext, local: LocalBlock, where: {
|
|
37
|
+
repoRoot: string;
|
|
38
|
+
stateDir: string;
|
|
39
|
+
secretsDir: string;
|
|
40
|
+
}, deps?: HostDeps): Promise<Prerequisite[]>;
|
|
41
|
+
/** The table as printed lines: `ok ` and `FAIL ` rows, each failure followed by its owner-run step. */
|
|
42
|
+
export declare function tableLines(rows: readonly Prerequisite[]): string[];
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// local-host.mjs - the host prerequisites of a dev environment, one row each: what is needed, whether this machine has it, and the exact
|
|
2
|
+
// install step the owner runs when it does not (winget on Windows, brew on macOS, apt on Linux). Read-only: it probes tools through the spawn
|
|
3
|
+
// seam, reads sizes and the app's declared ports, and installs nothing (rules.md rules 5 and 8; the owner runs the printed steps).
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { firstLine } from './context.mjs';
|
|
8
|
+
import { bindFree, holderOf } from './local-ports.mjs';
|
|
9
|
+
import { readRecord } from './local-state.mjs';
|
|
10
|
+
const INSTALL = {
|
|
11
|
+
node: { win32: 'winget install OpenJS.NodeJS.LTS', darwin: 'brew install node', linux: 'sudo apt install nodejs npm, or nvm install <version>' },
|
|
12
|
+
npm: { win32: 'winget install OpenJS.NodeJS.LTS (npm ships with Node)', darwin: 'brew install node (npm ships with Node)', linux: 'sudo apt install npm' },
|
|
13
|
+
git: { win32: 'winget install Git.Git', darwin: 'brew install git', linux: 'sudo apt install git' },
|
|
14
|
+
docker: { win32: 'winget install Docker.DockerDesktop, then start Docker Desktop and wait for the engine', darwin: 'brew install --cask docker, then open Docker', linux: 'sudo apt install docker.io, then sudo systemctl enable --now docker and add your user to the docker group' },
|
|
15
|
+
compose: { win32: 'update Docker Desktop (it ships Compose v2): winget upgrade Docker.DockerDesktop', darwin: 'brew upgrade --cask docker', linux: 'sudo apt install docker-compose-v2' },
|
|
16
|
+
wsl: { win32: 'wsl --install (an administrator terminal), then restart', darwin: 'not needed', linux: 'not needed' },
|
|
17
|
+
sops: { win32: 'winget install Mozilla.SOPS', darwin: 'brew install sops', linux: 'download the sops release for your CPU from https://github.com/getsops/sops/releases and put it on PATH' },
|
|
18
|
+
age: { win32: 'winget install FiloSottile.age', darwin: 'brew install age', linux: 'sudo apt install age' },
|
|
19
|
+
};
|
|
20
|
+
const platformOf = (ctx) => (ctx.platform === 'win32' || ctx.platform === 'darwin' ? ctx.platform : 'linux');
|
|
21
|
+
const NPM_SHIMS = new Set(['npm', 'npx']);
|
|
22
|
+
/**
|
|
23
|
+
* How to start `command` on this platform without a shell. npm and npx are `.cmd` shims on Windows, which cannot be spawned directly: they run as
|
|
24
|
+
* `node <npm-cli.js>` from the Node install beside this node when it is there, else through `cmd.exe /d /s /c`.
|
|
25
|
+
*/
|
|
26
|
+
export function launch(platform, command, args, exists = fs.existsSync) {
|
|
27
|
+
if (platform !== 'win32' || !NPM_SHIMS.has(command))
|
|
28
|
+
return [command, [...args]];
|
|
29
|
+
const cli = path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', `${command}-cli.js`);
|
|
30
|
+
return exists(cli) ? [process.execPath, [cli, ...args]] : ['cmd.exe', ['/d', '/s', '/c', command, ...args]];
|
|
31
|
+
}
|
|
32
|
+
/** Runs a host tool without a shell (see launch). */
|
|
33
|
+
export function hostTool(ctx, command, args, options = {}) {
|
|
34
|
+
const [file, argv] = launch(ctx.platform, command, args);
|
|
35
|
+
return ctx.spawn(file, argv, options);
|
|
36
|
+
}
|
|
37
|
+
const present = (run) => run.error === undefined && run.status === 0;
|
|
38
|
+
const versionOf = (text) => /(\d+)\.(\d+)(?:\.(\d+))?/u.exec(text)?.[0] ?? null;
|
|
39
|
+
const wantedOf = (text) => /\d+(?:\.\d+){0,2}/u.exec(text)?.[0] ?? null;
|
|
40
|
+
const atLeast = (have, need) => {
|
|
41
|
+
const [a = 0, b = 0, c = 0] = have.split('.').map(Number);
|
|
42
|
+
const [x = 0, y = 0, z = 0] = need.split('.').map(Number);
|
|
43
|
+
if (a !== x)
|
|
44
|
+
return a > x;
|
|
45
|
+
return b !== y ? b > y : c >= z;
|
|
46
|
+
};
|
|
47
|
+
/** The Node version the app asks for: `.nvmrc`, else the first version of `engines.node`; null when it asks for none. */
|
|
48
|
+
export function wantedNode(repoRoot) {
|
|
49
|
+
try {
|
|
50
|
+
return wantedOf(fs.readFileSync(path.join(repoRoot, '.nvmrc'), 'utf8'));
|
|
51
|
+
}
|
|
52
|
+
catch { /* no .nvmrc */ }
|
|
53
|
+
try {
|
|
54
|
+
return wantedOf(String(JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).engines?.node ?? ''));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const diskFree = (dir) => {
|
|
61
|
+
let at = dir;
|
|
62
|
+
while (!fs.existsSync(at) && path.dirname(at) !== at)
|
|
63
|
+
at = path.dirname(at);
|
|
64
|
+
try {
|
|
65
|
+
const stat = fs.statfsSync(at);
|
|
66
|
+
return Math.floor((stat.bavail * stat.bsize) / 1024 ** 3);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
function tool(ctx, id, row) {
|
|
73
|
+
const run = hostTool(ctx, row.command, row.args);
|
|
74
|
+
if (!present(run))
|
|
75
|
+
return { id, ok: false, detail: `${row.label} not found on PATH`, fix: INSTALL[id]?.[platformOf(ctx)] ?? null };
|
|
76
|
+
return { id, ok: true, detail: `${row.label} ${versionOf(`${run.stdout} ${run.stderr}`) ?? firstLine(run.stdout)}`, fix: null };
|
|
77
|
+
}
|
|
78
|
+
function docker(ctx) {
|
|
79
|
+
const platform = platformOf(ctx);
|
|
80
|
+
const cli = hostTool(ctx, 'docker', ['--version']);
|
|
81
|
+
if (!present(cli))
|
|
82
|
+
return [{ id: 'docker', ok: false, detail: 'docker not found on PATH', fix: INSTALL.docker?.[platform] ?? null }, { id: 'compose', ok: false, detail: 'needs docker first', fix: INSTALL.compose?.[platform] ?? null }];
|
|
83
|
+
const info = ctx.spawn('docker', ['info', '--format', '{{.ServerVersion}} {{.OSType}}']);
|
|
84
|
+
const [server, kind] = firstLine(info.stdout).split(' ');
|
|
85
|
+
const rows = [];
|
|
86
|
+
if (!present(info) || !server)
|
|
87
|
+
rows.push({ id: 'docker', ok: false, detail: 'the docker engine does not answer', fix: platform === 'linux' ? 'sudo systemctl start docker' : 'start Docker Desktop and wait until it reports the engine running' });
|
|
88
|
+
else if (kind !== 'linux')
|
|
89
|
+
rows.push({ id: 'docker', ok: false, detail: `docker serves ${kind} containers`, fix: 'switch Docker Desktop to Linux containers' });
|
|
90
|
+
else
|
|
91
|
+
rows.push({ id: 'docker', ok: true, detail: `docker engine ${server} (linux containers)`, fix: null });
|
|
92
|
+
const compose = ctx.spawn('docker', ['compose', 'version', '--short']);
|
|
93
|
+
const have = versionOf(compose.stdout);
|
|
94
|
+
if (!present(compose) || !have)
|
|
95
|
+
rows.push({ id: 'compose', ok: false, detail: 'docker compose v2 not found', fix: INSTALL.compose?.[platform] ?? null });
|
|
96
|
+
else if (!atLeast(have, '2.20'))
|
|
97
|
+
rows.push({ id: 'compose', ok: false, detail: `docker compose ${have} found, 2.20 or newer wanted`, fix: INSTALL.compose?.[platform] ?? null });
|
|
98
|
+
else
|
|
99
|
+
rows.push({ id: 'compose', ok: true, detail: `docker compose ${have}`, fix: null });
|
|
100
|
+
return rows;
|
|
101
|
+
}
|
|
102
|
+
/** True when the environment's secrets folder holds a sealed member. */
|
|
103
|
+
export const hasSealedMembers = (secretsDir) => {
|
|
104
|
+
try {
|
|
105
|
+
return fs.readdirSync(secretsDir).some((name) => /\.enc(\.yaml)?$/u.test(name));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
/** One row per declared port: free, or held by this environment's own record, or held by someone else (named). */
|
|
112
|
+
export async function portRows(ctx, local, stateDir, deps = {}) {
|
|
113
|
+
const free = deps.portFree ?? bindFree;
|
|
114
|
+
const own = new Set(Object.values(readRecord(stateDir).ports));
|
|
115
|
+
const rows = [];
|
|
116
|
+
for (const [name, port] of Object.entries(local.ports)) {
|
|
117
|
+
if (await free(port))
|
|
118
|
+
rows.push({ id: `port ${name}`, ok: true, detail: `${port} is free`, fix: null });
|
|
119
|
+
else if (own.has(port))
|
|
120
|
+
rows.push({ id: `port ${name}`, ok: true, detail: `${port} is held by this environment`, fix: null });
|
|
121
|
+
else if (holderOf(ctx, port).startsWith(`container ${local.project}-`))
|
|
122
|
+
rows.push({ id: `port ${name}`, ok: true, detail: `${port} is held by this environment's container`, fix: null });
|
|
123
|
+
else
|
|
124
|
+
rows.push({ id: `port ${name}`, ok: false, detail: `${port} is held by ${holderOf(ctx, port)}`, fix: `stop that holder, or change ports.${name} in .starcistacks/application-stacks.yaml` });
|
|
125
|
+
}
|
|
126
|
+
return rows;
|
|
127
|
+
}
|
|
128
|
+
function nodeRow(ctx, repoRoot) {
|
|
129
|
+
const want = wantedNode(repoRoot);
|
|
130
|
+
const node = ctx.spawn('node', ['--version']);
|
|
131
|
+
const have = versionOf(node.stdout);
|
|
132
|
+
if (present(node) && have && (!want || atLeast(have, want)))
|
|
133
|
+
return { id: 'node', ok: true, detail: `node ${have}${want ? ` (the app wants ${want} or newer)` : ''}`, fix: null };
|
|
134
|
+
return { id: 'node', ok: false, detail: present(node) ? `node ${have} found, ${want} or newer wanted by .nvmrc or engines.node` : 'node not found on PATH', fix: INSTALL.node?.[platformOf(ctx)] ?? null };
|
|
135
|
+
}
|
|
136
|
+
function resourceRows(local, stateDir, deps) {
|
|
137
|
+
const disk = (deps.diskFreeGb ?? diskFree)(stateDir);
|
|
138
|
+
const memory = (deps.memoryMb ?? (() => ({ free: Math.floor(os.freemem() / 1024 ** 2), total: Math.floor(os.totalmem() / 1024 ** 2) })))();
|
|
139
|
+
return [
|
|
140
|
+
disk === null || disk >= local.resources.diskGb
|
|
141
|
+
? { id: 'disk', ok: true, detail: disk === null ? 'free disk not readable here' : `${disk} GB free (${local.resources.diskGb} GB wanted)`, fix: null }
|
|
142
|
+
: { id: 'disk', ok: false, detail: `${disk} GB free, ${local.resources.diskGb} GB wanted`, fix: 'free disk space, or prune unused images with docker image prune (an owner step)' },
|
|
143
|
+
memory.total >= local.resources.ramMb
|
|
144
|
+
? { id: 'memory', ok: true, detail: `${memory.total} MB RAM (${memory.free} MB free, ${local.resources.ramMb} MB wanted)`, fix: null }
|
|
145
|
+
: { id: 'memory', ok: false, detail: `${memory.total} MB RAM, ${local.resources.ramMb} MB wanted`, fix: 'close other work or use a machine with more memory' },
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
/** The whole table for one environment: tools, engine, resources, then the declared ports. */
|
|
149
|
+
export async function hostPrerequisites(ctx, local, where, deps = {}) {
|
|
150
|
+
const rows = [nodeRow(ctx, where.repoRoot), tool(ctx, 'npm', { command: 'npm', args: ['--version'], label: 'npm' }), tool(ctx, 'git', { command: 'git', args: ['--version'], label: 'git' }), ...docker(ctx)];
|
|
151
|
+
if (ctx.platform === 'win32') {
|
|
152
|
+
const wsl = ctx.spawn('wsl.exe', ['--status']);
|
|
153
|
+
rows.push(present(wsl) ? { id: 'wsl', ok: true, detail: 'WSL2 is installed', fix: null } : { id: 'wsl', ok: false, detail: 'WSL2 is not available (Docker Desktop runs Linux containers on it)', fix: INSTALL.wsl?.win32 ?? null });
|
|
154
|
+
}
|
|
155
|
+
if (hasSealedMembers(where.secretsDir))
|
|
156
|
+
rows.push(tool(ctx, 'sops', { command: 'sops', args: ['--version'], label: 'sops' }), tool(ctx, 'age', { command: 'age', args: ['--version'], label: 'age' }));
|
|
157
|
+
rows.push(...resourceRows(local, where.stateDir, deps), ...await portRows(ctx, local, where.stateDir, deps));
|
|
158
|
+
return rows;
|
|
159
|
+
}
|
|
160
|
+
/** The table as printed lines: `ok ` and `FAIL ` rows, each failure followed by its owner-run step. */
|
|
161
|
+
export function tableLines(rows) {
|
|
162
|
+
return rows.flatMap((row) => [`${row.ok ? 'ok ' : 'FAIL '}${row.id}: ${row.detail}`, ...(row.ok || !row.fix ? [] : [` owner step: ${row.fix}`])]);
|
|
163
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { StackContext } from './context.mjs';
|
|
2
|
+
import type { ActionResult } from './remote.mjs';
|
|
3
|
+
import type { LocalConfig } from './local-config.mjs';
|
|
4
|
+
/** `logs` for a dev environment. */
|
|
5
|
+
export declare function logsLocal(ctx: StackContext, local: LocalConfig, { service, lines }?: {
|
|
6
|
+
service?: string | undefined;
|
|
7
|
+
lines?: number;
|
|
8
|
+
}): Promise<ActionResult>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// local-logs.mjs - `starci app stack logs --env <dev env> --service <name> [--lines N]` (PROJECT-STACKS-6): one named process or compose service,
|
|
2
|
+
// bounded in lines, redacted; never the environment or the credentials.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { lines as splitLines } from './context.mjs';
|
|
5
|
+
import { refusal } from './errors.mjs';
|
|
6
|
+
import { compose, redact, stateOf } from './local-run.mjs';
|
|
7
|
+
import { logFile } from './local-state.mjs';
|
|
8
|
+
const DEFAULT_LINES = 200;
|
|
9
|
+
const MAX_LINES = 2000;
|
|
10
|
+
/** `logs` for a dev environment. */
|
|
11
|
+
export async function logsLocal(ctx, local, { service, lines = DEFAULT_LINES } = {}) {
|
|
12
|
+
if (!service)
|
|
13
|
+
throw refusal('STACK_LOCAL_LOGS_SERVICE', `name one process or service with --service: ${[...local.processes.map((run) => run.name), ...local.services.map((item) => item.name)].join(', ')}`);
|
|
14
|
+
if (!Number.isInteger(lines) || lines < 1 || lines > MAX_LINES)
|
|
15
|
+
throw refusal('STACK_LOCAL_LOGS_LINES', `--lines is a whole number from 1 to ${MAX_LINES}`);
|
|
16
|
+
let text;
|
|
17
|
+
if (local.processes.some((run) => run.name === service)) {
|
|
18
|
+
try {
|
|
19
|
+
text = fs.readFileSync(logFile(stateOf(ctx, local), service), 'utf8');
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
text = '';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
else if (local.services.some((item) => item.name === service)) {
|
|
26
|
+
text = (await compose(ctx, local, ['logs', '--no-color', '--tail', String(lines), service])).stdout;
|
|
27
|
+
}
|
|
28
|
+
else
|
|
29
|
+
throw refusal('STACK_LOCAL_LOGS_SERVICE', `${service} is neither a process nor a service of ${local.env}`);
|
|
30
|
+
const tail = splitLines(text).slice(-lines).map(redact);
|
|
31
|
+
return { ok: true, lines: tail.length ? tail : [`${service}: no log lines yet`], data: { service, count: tail.length } };
|
|
32
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { StackContext } from './context.mjs';
|
|
2
|
+
/** True when nothing listens on the loopback `port` (a bind attempt, so a wildcard listener counts too). */
|
|
3
|
+
export declare const bindFree: (port: number) => Promise<boolean>;
|
|
4
|
+
/** True when something accepts a TCP connection on the loopback `port`. */
|
|
5
|
+
export declare const tcpOpen: (port: number) => Promise<boolean>;
|
|
6
|
+
/** What holds `port`: a docker container (by published port), else the process (netstat/tasklist on Windows, lsof elsewhere), else "an unknown process". */
|
|
7
|
+
export declare function holderOf(ctx: Pick<StackContext, 'spawn' | 'platform'>, port: number): string;
|