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 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,3 @@
1
+ DAP dashboard bundle
2
+ Built at: 2026-05-12T15:42:00Z
3
+ Git SHA: 3121895
@@ -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