dap-cli 0.3.0__py3-none-any.whl
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.
- dap_cli/__init__.py +1 -0
- dap_cli/__main__.py +99 -0
- dap_cli/_dashboard/.gitkeep +5 -0
- dap_cli/_dashboard/BUNDLE_INFO.txt +3 -0
- dap_cli/_dashboard/package.json +52 -0
- dap_cli/_dashboard/server.js +38 -0
- dap_cli/bootstrap.py +262 -0
- dap_cli/commands/__init__.py +0 -0
- dap_cli/commands/cortex.py +688 -0
- dap_cli/commands/init.py +183 -0
- dap_cli/commands/project.py +134 -0
- dap_cli/commands/start.py +124 -0
- dap_cli/commands/status.py +136 -0
- dap_cli/commands/stop.py +35 -0
- dap_cli/dashboard.py +99 -0
- dap_cli/paths.py +27 -0
- dap_cli/process.py +130 -0
- dap_cli/py.typed +0 -0
- dap_cli-0.3.0.dist-info/METADATA +43 -0
- dap_cli-0.3.0.dist-info/RECORD +22 -0
- dap_cli-0.3.0.dist-info/WHEEL +4 -0
- dap_cli-0.3.0.dist-info/entry_points.txt +2 -0
dap_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
dap_cli/__main__.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Typer entrypoint — `dap` CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from dap_cli import __version__
|
|
8
|
+
from dap_cli.commands.init import init_command
|
|
9
|
+
from dap_cli.commands.project import project_app
|
|
10
|
+
from dap_cli.commands.start import start_command
|
|
11
|
+
from dap_cli.commands.status import status_command
|
|
12
|
+
from dap_cli.commands.stop import stop_command
|
|
13
|
+
from dap_cli.paths import DEFAULT_DASHBOARD_PORT, DEFAULT_ENGINE_PORT
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
name="dap",
|
|
17
|
+
help="Deterministic Agent Pipeline — local launcher",
|
|
18
|
+
no_args_is_help=True,
|
|
19
|
+
add_completion=False,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
app.add_typer(project_app, name="project")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _version_callback(value: bool) -> None:
|
|
26
|
+
if value:
|
|
27
|
+
typer.echo(__version__)
|
|
28
|
+
raise typer.Exit()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.callback()
|
|
32
|
+
def root(
|
|
33
|
+
version: bool = typer.Option(
|
|
34
|
+
False,
|
|
35
|
+
"--version",
|
|
36
|
+
"-v",
|
|
37
|
+
callback=_version_callback,
|
|
38
|
+
is_eager=True,
|
|
39
|
+
help="Show version and exit",
|
|
40
|
+
),
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Deterministic Agent Pipeline — local launcher."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.command("init")
|
|
46
|
+
def cmd_init(
|
|
47
|
+
force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing .dap/"),
|
|
48
|
+
admin_email: str | None = typer.Option(
|
|
49
|
+
None,
|
|
50
|
+
"--admin-email",
|
|
51
|
+
help="Admin user email. Prompted interactively when omitted.",
|
|
52
|
+
),
|
|
53
|
+
admin_password: str | None = typer.Option(
|
|
54
|
+
None,
|
|
55
|
+
"--admin-password",
|
|
56
|
+
help=(
|
|
57
|
+
"Admin password (lands in shell history — use for local dev only). "
|
|
58
|
+
"Prefer --admin-password-stdin in automation."
|
|
59
|
+
),
|
|
60
|
+
),
|
|
61
|
+
admin_password_stdin: bool = typer.Option(
|
|
62
|
+
False,
|
|
63
|
+
"--admin-password-stdin",
|
|
64
|
+
help="Read the admin password from stdin (kubectl-style).",
|
|
65
|
+
),
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Initialize DAP project + bootstrap admin user."""
|
|
68
|
+
init_command(
|
|
69
|
+
force=force,
|
|
70
|
+
admin_email=admin_email,
|
|
71
|
+
admin_password=admin_password,
|
|
72
|
+
admin_password_stdin=admin_password_stdin,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command("start")
|
|
77
|
+
def cmd_start(
|
|
78
|
+
port: int = typer.Option(DEFAULT_DASHBOARD_PORT, "--port", "-p", help="Dashboard port"),
|
|
79
|
+
engine_port: int = typer.Option(DEFAULT_ENGINE_PORT, "--engine-port", help="Engine port"),
|
|
80
|
+
headless: bool = typer.Option(False, "--headless", help="Do not open browser"),
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Start engine + dashboard, open browser."""
|
|
83
|
+
start_command(port=port, engine_port=engine_port, headless=headless)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command("stop")
|
|
87
|
+
def cmd_stop() -> None:
|
|
88
|
+
"""Stop running engine + dashboard."""
|
|
89
|
+
stop_command()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command("status")
|
|
93
|
+
def cmd_status() -> None:
|
|
94
|
+
"""Show DAP project status."""
|
|
95
|
+
status_command()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
app()
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Placeholder so the directory always exists for hatchling's
|
|
2
|
+
# force-include. The dashboard bundle (server.js, .next/, etc.)
|
|
3
|
+
# is staged here on demand by scripts/build-dashboard-bundle.sh
|
|
4
|
+
# and is gitignored — see the gitignore entry for
|
|
5
|
+
# apps/cli/src/dap_cli/_dashboard/.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dap-dashboard",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "next start",
|
|
9
|
+
"typecheck": "tsc --noEmit",
|
|
10
|
+
"lint": "eslint .",
|
|
11
|
+
"gen:api": "openapi-typescript http://127.0.0.1:7333/openapi.json -o src/lib/api/types.gen.ts",
|
|
12
|
+
"clean": "rm -rf .next dist .turbo node_modules",
|
|
13
|
+
"test": "vitest run"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@hookform/resolvers": "4.1.3",
|
|
17
|
+
"@radix-ui/react-dialog": "1.1.15",
|
|
18
|
+
"@radix-ui/react-label": "2.1.1",
|
|
19
|
+
"@radix-ui/react-slot": "1.2.4",
|
|
20
|
+
"@radix-ui/react-tabs": "^1.1.13",
|
|
21
|
+
"@radix-ui/react-toast": "1.2.4",
|
|
22
|
+
"@tanstack/react-query": "5.100.9",
|
|
23
|
+
"@xyflow/react": "12.3.6",
|
|
24
|
+
"class-variance-authority": "0.7.1",
|
|
25
|
+
"clsx": "2.1.1",
|
|
26
|
+
"dagre": "^0.8.5",
|
|
27
|
+
"lucide-react": "0.469.0",
|
|
28
|
+
"next": "15.1.4",
|
|
29
|
+
"react": "19.0.0",
|
|
30
|
+
"react-dom": "19.0.0",
|
|
31
|
+
"react-hook-form": "7.54.2",
|
|
32
|
+
"tailwind-merge": "2.6.0",
|
|
33
|
+
"tailwindcss-animate": "1.0.7",
|
|
34
|
+
"zod": "3.24.1"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@eslint/eslintrc": "3.3.5",
|
|
38
|
+
"@types/dagre": "^0.7.54",
|
|
39
|
+
"@types/node": "25.6.2",
|
|
40
|
+
"@types/react": "19.0.7",
|
|
41
|
+
"@types/react-dom": "19.0.3",
|
|
42
|
+
"@vitest/coverage-v8": "^4.1.5",
|
|
43
|
+
"autoprefixer": "10.4.20",
|
|
44
|
+
"eslint": "9.39.4",
|
|
45
|
+
"eslint-config-next": "15.5.15",
|
|
46
|
+
"openapi-typescript": "7.5.2",
|
|
47
|
+
"postcss": "8.4.49",
|
|
48
|
+
"tailwindcss": "3.4.17",
|
|
49
|
+
"typescript": "5.9.3",
|
|
50
|
+
"vitest": "^4.1.5"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
|
|
3
|
+
const dir = path.join(__dirname)
|
|
4
|
+
|
|
5
|
+
process.env.NODE_ENV = 'production'
|
|
6
|
+
process.chdir(__dirname)
|
|
7
|
+
|
|
8
|
+
const currentPort = parseInt(process.env.PORT, 10) || 3000
|
|
9
|
+
const hostname = process.env.HOSTNAME || '0.0.0.0'
|
|
10
|
+
|
|
11
|
+
let keepAliveTimeout = parseInt(process.env.KEEP_ALIVE_TIMEOUT, 10)
|
|
12
|
+
const nextConfig = {"env":{"NEXT_PUBLIC_DAP_ENGINE_URL":"http://127.0.0.1:7333"},"webpack":null,"eslint":{"ignoreDuringBuilds":true},"typescript":{"ignoreBuildErrors":false,"tsconfigPath":"tsconfig.json"},"distDir":"./.next","cleanDistDir":true,"assetPrefix":"","cacheMaxMemorySize":52428800,"configOrigin":"next.config.ts","useFileSystemPublicRoutes":true,"generateEtags":true,"pageExtensions":["tsx","ts","jsx","js"],"poweredByHeader":true,"compress":true,"images":{"deviceSizes":[640,750,828,1080,1200,1920,2048,3840],"imageSizes":[16,32,48,64,96,128,256,384],"path":"/_next/image","loader":"default","loaderFile":"","domains":[],"disableStaticImages":false,"minimumCacheTTL":60,"formats":["image/webp"],"dangerouslyAllowSVG":false,"contentSecurityPolicy":"script-src 'none'; frame-src 'none'; sandbox;","contentDispositionType":"attachment","remotePatterns":[],"unoptimized":false},"devIndicators":{"appIsrStatus":true,"buildActivity":true,"buildActivityPosition":"bottom-right"},"onDemandEntries":{"maxInactiveAge":60000,"pagesBufferLength":5},"amp":{"canonicalBase":""},"basePath":"","sassOptions":{},"trailingSlash":false,"i18n":null,"productionBrowserSourceMaps":false,"excludeDefaultMomentLocales":true,"serverRuntimeConfig":{},"publicRuntimeConfig":{},"reactProductionProfiling":false,"reactStrictMode":true,"reactMaxHeadersLength":6000,"httpAgentOptions":{"keepAlive":true},"logging":{},"expireTime":31536000,"staticPageGenerationTimeout":60,"output":"standalone","modularizeImports":{"@mui/icons-material":{"transform":"@mui/icons-material/{{member}}"},"lodash":{"transform":"lodash/{{member}}"}},"outputFileTracingRoot":"/home/runner/work/dap/dap/apps/dashboard","experimental":{"cacheLife":{"default":{"stale":300,"revalidate":900,"expire":4294967294},"seconds":{"stale":0,"revalidate":1,"expire":60},"minutes":{"stale":300,"revalidate":60,"expire":3600},"hours":{"stale":300,"revalidate":3600,"expire":86400},"days":{"stale":300,"revalidate":86400,"expire":604800},"weeks":{"stale":300,"revalidate":604800,"expire":2592000},"max":{"stale":300,"revalidate":2592000,"expire":4294967294}},"cacheHandlers":{},"cssChunking":true,"multiZoneDraftMode":false,"appNavFailHandling":false,"prerenderEarlyExit":true,"serverMinification":true,"serverSourceMaps":false,"linkNoTouchStart":false,"caseSensitiveRoutes":false,"clientSegmentCache":false,"preloadEntriesOnStart":true,"clientRouterFilter":true,"clientRouterFilterRedirects":false,"fetchCacheKeyPrefix":"","middlewarePrefetch":"flexible","optimisticClientCache":true,"manualClientBasePath":false,"cpus":3,"memoryBasedWorkersCount":false,"imgOptConcurrency":null,"imgOptTimeoutInSeconds":7,"imgOptMaxInputPixels":268402689,"imgOptSequentialRead":null,"isrFlushToDisk":true,"workerThreads":false,"optimizeCss":false,"nextScriptWorkers":false,"scrollRestoration":false,"externalDir":false,"disableOptimizedLoading":false,"gzipSize":true,"craCompat":false,"esmExternals":true,"fullySpecified":false,"swcTraceProfiling":false,"forceSwcTransforms":false,"largePageDataBytes":128000,"turbo":{"root":"/home/runner/work/dap/dap/apps/dashboard"},"typedRoutes":false,"typedEnv":false,"parallelServerCompiles":false,"parallelServerBuildTraces":false,"ppr":false,"authInterrupts":false,"reactOwnerStack":false,"webpackMemoryOptimizations":false,"optimizeServerReact":true,"useEarlyImport":false,"staleTimes":{"dynamic":0,"static":300},"serverComponentsHmrCache":true,"staticGenerationMaxConcurrency":8,"staticGenerationMinPagesPerWorker":25,"dynamicIO":false,"inlineCss":false,"optimizePackageImports":["lucide-react","date-fns","lodash-es","ramda","antd","react-bootstrap","ahooks","@ant-design/icons","@headlessui/react","@headlessui-float/react","@heroicons/react/20/solid","@heroicons/react/24/solid","@heroicons/react/24/outline","@visx/visx","@tremor/react","rxjs","@mui/material","@mui/icons-material","recharts","react-use","effect","@effect/schema","@effect/platform","@effect/platform-node","@effect/platform-browser","@effect/platform-bun","@effect/sql","@effect/sql-mssql","@effect/sql-mysql2","@effect/sql-pg","@effect/sql-squlite-node","@effect/sql-squlite-bun","@effect/sql-squlite-wasm","@effect/sql-squlite-react-native","@effect/rpc","@effect/rpc-http","@effect/typeclass","@effect/experimental","@effect/opentelemetry","@material-ui/core","@material-ui/icons","@tabler/icons-react","mui-core","react-icons/ai","react-icons/bi","react-icons/bs","react-icons/cg","react-icons/ci","react-icons/di","react-icons/fa","react-icons/fa6","react-icons/fc","react-icons/fi","react-icons/gi","react-icons/go","react-icons/gr","react-icons/hi","react-icons/hi2","react-icons/im","react-icons/io","react-icons/io5","react-icons/lia","react-icons/lib","react-icons/lu","react-icons/md","react-icons/pi","react-icons/ri","react-icons/rx","react-icons/si","react-icons/sl","react-icons/tb","react-icons/tfi","react-icons/ti","react-icons/vsc","react-icons/wi"],"trustHostHeader":false,"isExperimentalCompile":false},"bundlePagesRouterDependencies":false,"configFileName":"next.config.ts"}
|
|
13
|
+
|
|
14
|
+
process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(nextConfig)
|
|
15
|
+
|
|
16
|
+
require('next')
|
|
17
|
+
const { startServer } = require('next/dist/server/lib/start-server')
|
|
18
|
+
|
|
19
|
+
if (
|
|
20
|
+
Number.isNaN(keepAliveTimeout) ||
|
|
21
|
+
!Number.isFinite(keepAliveTimeout) ||
|
|
22
|
+
keepAliveTimeout < 0
|
|
23
|
+
) {
|
|
24
|
+
keepAliveTimeout = undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
startServer({
|
|
28
|
+
dir,
|
|
29
|
+
isDev: false,
|
|
30
|
+
config: nextConfig,
|
|
31
|
+
hostname,
|
|
32
|
+
port: currentPort,
|
|
33
|
+
allowRetry: false,
|
|
34
|
+
keepAliveTimeout,
|
|
35
|
+
}).catch((err) => {
|
|
36
|
+
console.error(err);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
});
|
dap_cli/bootstrap.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Admin-user bootstrap for ``dap init`` (#302 sub-D4).
|
|
2
|
+
|
|
3
|
+
Creates (or promotes) an admin user on the engine's local SQLite
|
|
4
|
+
database without going through the HTTP API — the engine doesn't have
|
|
5
|
+
to be running. Re-uses ``fastapi-users``' password hasher (Argon2id
|
|
6
|
+
via pwdlib) so the resulting row is indistinguishable from one
|
|
7
|
+
created by ``/auth/register``.
|
|
8
|
+
|
|
9
|
+
Idempotent: if a user with the given email already exists, their
|
|
10
|
+
``is_superuser`` flag is set to ``True`` and the row is returned
|
|
11
|
+
unchanged otherwise.
|
|
12
|
+
|
|
13
|
+
The function writes ``.dap/bootstrap.json`` (chmod 600) with metadata
|
|
14
|
+
the operator (and ``dap status``) can inspect later — `email`,
|
|
15
|
+
`user_id`, `created_at`, `created_existing` flag.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import contextlib
|
|
21
|
+
import datetime as _dt
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
import secrets
|
|
25
|
+
import uuid
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
# Engine internals — same workspace, no public-API surface needed.
|
|
31
|
+
from dap_engine.app import EngineConfig
|
|
32
|
+
from dap_engine.persistence.db import create_engine_for_sqlite
|
|
33
|
+
from dap_engine.persistence.migrations import apply_migrations
|
|
34
|
+
from dap_engine.persistence.models import UserORM
|
|
35
|
+
from fastapi_users.password import PasswordHelper
|
|
36
|
+
from sqlalchemy import select
|
|
37
|
+
from sqlalchemy.orm import Session
|
|
38
|
+
|
|
39
|
+
# Engine-side password policy lives in ``UserManager.validate_password``;
|
|
40
|
+
# mirror it here so the CLI rejects bad passwords before they touch
|
|
41
|
+
# the DB. Keeping the value in lockstep with sub-A1's MIN_PASSWORD_LENGTH
|
|
42
|
+
# is a maintenance burden, but the engine module isn't importable
|
|
43
|
+
# without bootstrapping its DB — we accept the duplication for now.
|
|
44
|
+
MIN_PASSWORD_LENGTH = 8
|
|
45
|
+
|
|
46
|
+
# Generated random passwords. 22 base64url chars = 132 bits of entropy —
|
|
47
|
+
# plenty for any policy and short enough to read off a terminal.
|
|
48
|
+
GENERATED_PASSWORD_LENGTH = 22
|
|
49
|
+
|
|
50
|
+
# RFC 5322 covers an enormous surface; this regex catches typos
|
|
51
|
+
# (missing @, missing TLD) without claiming full RFC compliance. The
|
|
52
|
+
# engine's ``EmailStr`` validator catches the rest at register time
|
|
53
|
+
# if anyone gets through this guard.
|
|
54
|
+
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class BootstrapResult:
|
|
59
|
+
"""What ``ensure_admin_user`` did, surfaced to the caller for UI."""
|
|
60
|
+
|
|
61
|
+
user_id: uuid.UUID
|
|
62
|
+
email: str
|
|
63
|
+
generated_password: str | None
|
|
64
|
+
"""The password the caller should print *once* (when ``dap init``
|
|
65
|
+
generated it). ``None`` when the operator supplied one — we never
|
|
66
|
+
echo a user-typed password."""
|
|
67
|
+
promoted_existing: bool
|
|
68
|
+
"""True when the email already had a row and we flipped
|
|
69
|
+
``is_superuser`` to True. False when we inserted a fresh row."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def validate_email(value: str) -> str:
|
|
73
|
+
"""Return a normalised email or raise ``ValueError``.
|
|
74
|
+
|
|
75
|
+
Leading/trailing whitespace is stripped; the local part keeps
|
|
76
|
+
case (RFC says it's case-sensitive, although every mail server
|
|
77
|
+
in practice treats it case-insensitive). Email-validator
|
|
78
|
+
boundary cases (IDN, comments) aren't supported — the engine's
|
|
79
|
+
register endpoint has the full validator if needed.
|
|
80
|
+
"""
|
|
81
|
+
candidate = value.strip()
|
|
82
|
+
if not _EMAIL_RE.match(candidate):
|
|
83
|
+
raise ValueError(f"not an email-looking value: {candidate!r}")
|
|
84
|
+
return candidate
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def validate_password(value: str) -> None:
|
|
88
|
+
"""Raise ``ValueError`` if the password violates the policy."""
|
|
89
|
+
if len(value) < MIN_PASSWORD_LENGTH:
|
|
90
|
+
raise ValueError(f"password must be at least {MIN_PASSWORD_LENGTH} characters")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def generate_password() -> str:
|
|
94
|
+
"""Cryptographically-secure random password suitable for printing."""
|
|
95
|
+
return secrets.token_urlsafe(GENERATED_PASSWORD_LENGTH)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def ensure_admin_user(
|
|
99
|
+
db_path: Path,
|
|
100
|
+
*,
|
|
101
|
+
email: str,
|
|
102
|
+
password: str | None,
|
|
103
|
+
) -> BootstrapResult:
|
|
104
|
+
"""Create or promote the admin user.
|
|
105
|
+
|
|
106
|
+
``password`` ``None`` → ``ensure_admin_user`` generates one. When
|
|
107
|
+
the caller supplies a password, the function never returns it
|
|
108
|
+
(so the calling code can't accidentally log the secret).
|
|
109
|
+
|
|
110
|
+
Operates on the engine's local SQLite file directly. Migrations
|
|
111
|
+
are applied first so a fresh ``.dap/state.db`` becomes
|
|
112
|
+
structurally valid before the row write.
|
|
113
|
+
"""
|
|
114
|
+
email = validate_email(email)
|
|
115
|
+
generated: str | None = None
|
|
116
|
+
if password is None or password == "":
|
|
117
|
+
password = generate_password()
|
|
118
|
+
generated = password
|
|
119
|
+
validate_password(password)
|
|
120
|
+
|
|
121
|
+
# Need a JWT secret on the EngineConfig to instantiate
|
|
122
|
+
# ``create_engine_for_sqlite`` cleanly — the value doesn't matter
|
|
123
|
+
# for the bootstrap operation (no token signing happens here),
|
|
124
|
+
# but the engine refuses to construct an app without one. A
|
|
125
|
+
# throwaway random keeps the helper standalone.
|
|
126
|
+
EngineConfig(
|
|
127
|
+
db_path=str(db_path),
|
|
128
|
+
auth_jwt_secret=secrets.token_urlsafe(32),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
engine = create_engine_for_sqlite(str(db_path))
|
|
133
|
+
apply_migrations(engine)
|
|
134
|
+
|
|
135
|
+
hasher = PasswordHelper()
|
|
136
|
+
hashed_password = hasher.hash(password)
|
|
137
|
+
|
|
138
|
+
with Session(engine) as session:
|
|
139
|
+
# ``.unique()`` is required because ``UserORM`` declares a
|
|
140
|
+
# joined eager-load on ``oauth_identities`` (a collection) —
|
|
141
|
+
# without de-duplication SQLAlchemy refuses to materialise a
|
|
142
|
+
# single row from the multi-row JOIN result.
|
|
143
|
+
existing = (
|
|
144
|
+
session.scalars(
|
|
145
|
+
select(UserORM).where(UserORM.email == email) # type: ignore[arg-type]
|
|
146
|
+
)
|
|
147
|
+
.unique()
|
|
148
|
+
.one_or_none()
|
|
149
|
+
)
|
|
150
|
+
if existing is not None:
|
|
151
|
+
# ``promoted_existing`` means "we found an existing row and
|
|
152
|
+
# left/forced it to admin", not "we flipped is_superuser
|
|
153
|
+
# this call" — operators care about idempotency, not the
|
|
154
|
+
# exact prior state. Keep this stable on re-runs.
|
|
155
|
+
existing.is_superuser = True
|
|
156
|
+
existing.is_active = True
|
|
157
|
+
existing.deleted_at = None
|
|
158
|
+
# Password-reset path (sub-E3 Copilot review): when the
|
|
159
|
+
# operator supplied an **explicit** password (not the
|
|
160
|
+
# auto-generate flow), apply it on the existing row. This
|
|
161
|
+
# is the documented lost-admin-password recovery flow —
|
|
162
|
+
# ``dap init --force --admin-email=X --admin-password=Y``
|
|
163
|
+
# both promotes X to admin AND sets Y as the password.
|
|
164
|
+
# We deliberately do *not* apply auto-generated passwords
|
|
165
|
+
# to existing users; on re-run with no ``--admin-password``,
|
|
166
|
+
# the existing credentials stay intact (silently rotating
|
|
167
|
+
# to a fresh random would break the operator's working
|
|
168
|
+
# login). ``generated is None`` is the unambiguous signal
|
|
169
|
+
# for "operator supplied this value explicitly".
|
|
170
|
+
if generated is None:
|
|
171
|
+
existing.hashed_password = hashed_password
|
|
172
|
+
session.commit()
|
|
173
|
+
return BootstrapResult(
|
|
174
|
+
user_id=existing.id,
|
|
175
|
+
email=email,
|
|
176
|
+
# Never expose a generated password for an existing
|
|
177
|
+
# user — we didn't apply it, so returning it would
|
|
178
|
+
# mislead the operator into thinking the password
|
|
179
|
+
# changed.
|
|
180
|
+
generated_password=None,
|
|
181
|
+
promoted_existing=True,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
now = _dt.datetime.now(_dt.UTC)
|
|
185
|
+
user = UserORM(
|
|
186
|
+
id=uuid.uuid4(),
|
|
187
|
+
email=email,
|
|
188
|
+
hashed_password=hashed_password,
|
|
189
|
+
is_active=True,
|
|
190
|
+
is_superuser=True,
|
|
191
|
+
is_verified=True,
|
|
192
|
+
created_at=now,
|
|
193
|
+
updated_at=now,
|
|
194
|
+
deleted_at=None,
|
|
195
|
+
last_login_at=None,
|
|
196
|
+
)
|
|
197
|
+
session.add(user)
|
|
198
|
+
session.commit()
|
|
199
|
+
return BootstrapResult(
|
|
200
|
+
user_id=user.id,
|
|
201
|
+
email=email,
|
|
202
|
+
generated_password=generated,
|
|
203
|
+
promoted_existing=False,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def write_bootstrap_marker(path: Path, result: BootstrapResult) -> None:
|
|
208
|
+
"""Persist a small ``bootstrap.json`` next to ``state.db`` so
|
|
209
|
+
``dap status`` can show "admin user X created on Y".
|
|
210
|
+
|
|
211
|
+
No secret material in the file — the generated password (when
|
|
212
|
+
present) is only printed to stdout, never written.
|
|
213
|
+
"""
|
|
214
|
+
payload = {
|
|
215
|
+
"email": result.email,
|
|
216
|
+
"user_id": str(result.user_id),
|
|
217
|
+
"created_at": _dt.datetime.now(_dt.UTC).isoformat(),
|
|
218
|
+
"promoted_existing": result.promoted_existing,
|
|
219
|
+
}
|
|
220
|
+
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
221
|
+
# ``chmod 600`` — the file contains no secrets, but locking down
|
|
222
|
+
# the permissions establishes the right reflex for any future
|
|
223
|
+
# additions (e.g. recovery tokens). Best-effort on Windows where
|
|
224
|
+
# POSIX modes don't apply.
|
|
225
|
+
with contextlib.suppress(OSError, NotImplementedError):
|
|
226
|
+
path.chmod(0o600)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def read_bootstrap_marker(path: Path) -> dict[str, Any] | None:
|
|
230
|
+
"""Read the bootstrap marker if it exists. ``None`` on missing /
|
|
231
|
+
corrupt — caller treats either as "no bootstrap yet".
|
|
232
|
+
|
|
233
|
+
Returns ``dict[str, Any]`` because the payload mixes strings
|
|
234
|
+
(email, user_id, created_at) with booleans (``promoted_existing``).
|
|
235
|
+
"""
|
|
236
|
+
if not path.is_file():
|
|
237
|
+
return None
|
|
238
|
+
try:
|
|
239
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
240
|
+
except (OSError, json.JSONDecodeError):
|
|
241
|
+
return None
|
|
242
|
+
if not isinstance(data, dict):
|
|
243
|
+
return None
|
|
244
|
+
return data
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def bootstrap_marker_path(dap_dir: Path) -> Path:
|
|
248
|
+
return dap_dir / "bootstrap.json"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
__all__ = [
|
|
252
|
+
"GENERATED_PASSWORD_LENGTH",
|
|
253
|
+
"MIN_PASSWORD_LENGTH",
|
|
254
|
+
"BootstrapResult",
|
|
255
|
+
"bootstrap_marker_path",
|
|
256
|
+
"ensure_admin_user",
|
|
257
|
+
"generate_password",
|
|
258
|
+
"read_bootstrap_marker",
|
|
259
|
+
"validate_email",
|
|
260
|
+
"validate_password",
|
|
261
|
+
"write_bootstrap_marker",
|
|
262
|
+
]
|
|
File without changes
|