@mriekki/lean 0.4.6 → 0.4.7

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "package": "@mriekki/lean",
4
- "version": "0.4.6",
4
+ "version": "0.4.7",
5
5
  "defaultProfile": "greenfield-poc",
6
6
  "profiles": {
7
7
  "greenfield-poc": {
@@ -14,7 +14,7 @@
14
14
  "components": {
15
15
  "core": {
16
16
  "repository": "mriekki/lean-agentic-core",
17
- "commit": "52905468c06fe39caa491a15a77e33cd400a60d9",
17
+ "commit": "c53620078d22ec8acb6fee99006045736a2d6491",
18
18
  "version": "1.13.0",
19
19
  "distribution": "greenfield-workspace",
20
20
  "descriptor": "assets/core/distribution.json",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "deployment": {
25
25
  "repository": "mriekki/lean-poc-deployment-kit",
26
- "commit": "bfe07aac1809d4cf41428fba9fdbcfe6a5e85470",
26
+ "commit": "a4cdb4f43982692536b669a135a4c81057368fce",
27
27
  "distribution": "greenfield-poc",
28
28
  "descriptor": "assets/deployment/distribution.json",
29
29
  "sourceRoot": "assets/generated/deployment",
@@ -20,6 +20,7 @@
20
20
  {"source":"core/skills/core-sync/SKILL.md","destination":".github/skills/core-sync/SKILL.md"},
21
21
  {"source":"core/skills/upstream-fix/SKILL.md","destination":".github/skills/upstream-fix/SKILL.md"},
22
22
  {"source":"core/skills/wait-what/SKILL.md","destination":".github/skills/wait-what/SKILL.md"},
23
+ {"source":"core/skills/developer-env-mode/SKILL.md","destination":".github/skills/developer-env-mode/SKILL.md"},
23
24
  {"source":"core/skills/deployment-readiness/SKILL.md","destination":".github/skills/deployment-readiness/SKILL.md"},
24
25
  {"source":"core/skills/deploy/SKILL.md","destination":".github/skills/deploy/SKILL.md"},
25
26
  {"source":"core/skills/tear-down/SKILL.md","destination":".github/skills/tear-down/SKILL.md"},
@@ -11,6 +11,8 @@
11
11
  {"source": "src/lean_poc_deployment/app_runtime_remote.py", "destination": "tools/lean-deployment/lean_poc_deployment/app_runtime_remote.py"},
12
12
  {"source": "src/lean_poc_deployment/local_cli.py", "destination": "tools/lean-deployment/lean_poc_deployment/local_cli.py"},
13
13
  {"source": "src/lean_poc_deployment/local_runtime.py", "destination": "tools/lean-deployment/lean_poc_deployment/local_runtime.py"},
14
+ {"source": "src/lean_poc_deployment/local_spec.py", "destination": "tools/lean-deployment/lean_poc_deployment/local_spec.py"},
15
+ {"source": "src/lean_poc_deployment/local_sqlite.py", "destination": "tools/lean-deployment/lean_poc_deployment/local_sqlite.py"},
14
16
  {"source": "src/lean_poc_deployment/box_platform.py", "destination": "tools/lean-deployment/lean_poc_deployment/box_platform.py"},
15
17
  {"source": "src/lean_poc_deployment/box_platform_assets.py", "destination": "tools/lean-deployment/lean_poc_deployment/box_platform_assets.py"},
16
18
  {"source": "src/lean_poc_deployment/box_platform_remote.py", "destination": "tools/lean-deployment/lean_poc_deployment/box_platform_remote.py"},
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "package": "@mriekki/lean",
4
- "version": "0.4.6",
4
+ "version": "0.4.7",
5
5
  "components": {
6
6
  "core": {
7
7
  "repository": "mriekki/lean-agentic-core",
8
- "commit": "52905468c06fe39caa491a15a77e33cd400a60d9",
8
+ "commit": "c53620078d22ec8acb6fee99006045736a2d6491",
9
9
  "version": "1.13.0",
10
10
  "distribution": "greenfield-workspace",
11
11
  "files": [
@@ -99,6 +99,11 @@
99
99
  "destination": ".github/skills/wait-what/SKILL.md",
100
100
  "sha256": "9b5968158992681837071b60465682a887540b1fdab510861ed739ffa996fbdb"
101
101
  },
102
+ {
103
+ "source": "core/skills/developer-env-mode/SKILL.md",
104
+ "destination": ".github/skills/developer-env-mode/SKILL.md",
105
+ "sha256": "23a026a3c2e999855fe64006e76bffcbecf6348696266fd94452c605ef1d4c03"
106
+ },
102
107
  {
103
108
  "source": "core/skills/deployment-readiness/SKILL.md",
104
109
  "destination": ".github/skills/deployment-readiness/SKILL.md",
@@ -298,7 +303,7 @@
298
303
  },
299
304
  "deployment": {
300
305
  "repository": "mriekki/lean-poc-deployment-kit",
301
- "commit": "bfe07aac1809d4cf41428fba9fdbcfe6a5e85470",
306
+ "commit": "a4cdb4f43982692536b669a135a4c81057368fce",
302
307
  "distribution": "greenfield-poc",
303
308
  "files": [
304
309
  {
@@ -334,13 +339,23 @@
334
339
  {
335
340
  "source": "src/lean_poc_deployment/local_cli.py",
336
341
  "destination": "tools/lean-deployment/lean_poc_deployment/local_cli.py",
337
- "sha256": "9c658c0409da3c6297ad349035bccb263a587cd6d9b07a9796ccbb7cdb6676f9"
342
+ "sha256": "b183d937b21cef10c4766d588dccd03a729a373e587a82e7b80a9e2390797eb9"
338
343
  },
339
344
  {
340
345
  "source": "src/lean_poc_deployment/local_runtime.py",
341
346
  "destination": "tools/lean-deployment/lean_poc_deployment/local_runtime.py",
342
347
  "sha256": "82cf795733b59f4591515396be65317da4e515fde19874e3c5851bcff412d84d"
343
348
  },
349
+ {
350
+ "source": "src/lean_poc_deployment/local_spec.py",
351
+ "destination": "tools/lean-deployment/lean_poc_deployment/local_spec.py",
352
+ "sha256": "372b8c041edd9d3a1005861d85bb3a6bad38064b0be081a470460ad2dc62be4d"
353
+ },
354
+ {
355
+ "source": "src/lean_poc_deployment/local_sqlite.py",
356
+ "destination": "tools/lean-deployment/lean_poc_deployment/local_sqlite.py",
357
+ "sha256": "767c91f2af1f0df6f88b81a867826e4beaa1871865a97a9002a6b3fc53c781c9"
358
+ },
344
359
  {
345
360
  "source": "src/lean_poc_deployment/box_platform.py",
346
361
  "destination": "tools/lean-deployment/lean_poc_deployment/box_platform.py",
@@ -0,0 +1,231 @@
1
+ ---
2
+ name: developer-env-mode
3
+ description: "Converge and verify the developer workstation for local-only or remote-enabled work. Mode changes become effective only after the selected readiness contract passes."
4
+ argument-hint: "status|local|remote"
5
+ user-invocable: true
6
+ disable-model-invocation: true
7
+ ---
8
+
9
+ # Developer Environment Mode
10
+
11
+ `/developer-env-mode` manages the current workstation's operational development mode. It is not a deployment command and it does not weaken Staging or Production gates.
12
+
13
+ Supported intents:
14
+
15
+ ```text
16
+ /developer-env-mode status
17
+ /developer-env-mode local
18
+ /developer-env-mode remote
19
+ ```
20
+
21
+ The governing invariant is:
22
+
23
+ ```text
24
+ MODE != preference
25
+ MODE = verified operational state
26
+ ```
27
+
28
+ Never record a requested mode as ready until its readiness contract passes. If convergence fails, preserve the prior ready mode when one exists and report the blocker.
29
+
30
+ ## 1. Resolve repository and package provenance read-only
31
+
32
+ From the repository root inspect, when present:
33
+
34
+ - `POC_BOOTSTRAP.json`;
35
+ - `LEAN_BASELINE.json` and component baselines;
36
+ - `DEV_ENVIRONMENT.json`;
37
+ - application setup/build/test commands and project-owned persistence configuration;
38
+ - deployment topology and generated deployment tooling;
39
+ - Git status so unrelated work is preserved.
40
+
41
+ For package-managed consumers, verify managed files are coherent before relying on distributed Core or Deployment tooling. Route managed drift to `/lean-update` rather than overwriting it.
42
+
43
+ Do not require a Bootstrap/Core/Deployment Kit source checkout for normal consumer use.
44
+
45
+ ## 2. Keep current mode state workstation-local
46
+
47
+ Mode state is user/workstation state, not repository authority. Store it outside the repository under the user-scoped Lean directory, using a repository-scoped location such as:
48
+
49
+ ```text
50
+ ~/.lean-poc/workspaces/<owner>/<repo>/developer-environment.json
51
+ ```
52
+
53
+ Do not commit this state. Do not store credentials, private keys, target trust stores, database contents, or secret values in it.
54
+
55
+ Record enough non-secret evidence to distinguish requested mode from readiness, for example:
56
+
57
+ - mode;
58
+ - ready/not-ready;
59
+ - verification timestamp;
60
+ - source revision when available;
61
+ - build/test state or fingerprint;
62
+ - local database state when applicable;
63
+ - local runtime health when applicable;
64
+ - remote target/access readiness summary when applicable.
65
+
66
+ A later `status` check must be allowed to report that the recorded mode is no longer ready when Docker stops, tools drift, the build becomes stale, the local database is missing/incompatible, or remote access disappears.
67
+
68
+ ## 3. `status` is read-only
69
+
70
+ `/developer-env-mode status` performs no setup writes. Inspect current workstation evidence and live prerequisites, then report both the selected mode and current readiness.
71
+
72
+ Prefer a compact result such as:
73
+
74
+ ```text
75
+ DEVELOPER ENVIRONMENT
76
+
77
+ Selected mode: LOCAL
78
+ Readiness: NOT READY
79
+
80
+ Docker ........ READY
81
+ Build ......... STALE
82
+ Tests ......... READY
83
+ SQLite ........ READY
84
+ Runtime ....... HEALTHY
85
+ Remote access . UNAVAILABLE
86
+
87
+ NEXT
88
+ /developer-env-mode local
89
+ ```
90
+
91
+ Do not silently repair state during `status`.
92
+
93
+ ## 4. `local` means converge a complete local development environment
94
+
95
+ Selecting `local` authorizes bounded, non-destructive workstation setup needed to make the current repository usable locally. It does not authorize destructive database reset, deletion of unrelated Docker state, remote publication, remote deployment, or Production writes.
96
+
97
+ Converge in this order.
98
+
99
+ ### A. Workstation prerequisites
100
+
101
+ Verify the current OS is supported by repository configuration. Use repository setup/doctor tooling when present to verify required tools and versions.
102
+
103
+ For Docker-based local execution require a working Docker CLI/engine. Prefer repository-distributed Deployment Kit commands when `DEPLOYMENT_BASELINE.json` proves they are installed.
104
+
105
+ ### B. Dependencies and restore
106
+
107
+ Run repository-defined dependency restore/setup commands when missing or stale. Preserve pinned versions and project conventions. Do not substitute arbitrary global tooling when the repository defines a supported path.
108
+
109
+ ### C. Local persistence
110
+
111
+ If the application uses SQLite for local development:
112
+
113
+ 1. resolve the project-owned database filename/container path and migration/initialization contract from repository configuration/docs/code;
114
+ 2. prefer the installed Deployment Kit local SQLite ensure/status primitive when available for workstation-owned persistent storage;
115
+ 3. when the database is absent, create the owned local SQLite file/directory non-destructively;
116
+ 4. run the repository-provided migration/schema initialization needed to make that database usable;
117
+ 5. verify the resulting database can be opened and that the application-required schema/migration state is current when the repository provides such a check.
118
+
119
+ If an existing SQLite database is corrupt, incompatible, or has unexpected ownership/schema state, **STOP**. Never delete, replace, reset, or migrate destructively merely to make mode convergence pass. Present the exact recovery/reset decision separately.
120
+
121
+ Do not assume every project uses SQLite. Other persistence requires an explicit repository-owned local setup contract.
122
+
123
+ ### D. Build and tests
124
+
125
+ Run the repository's required local build and test commands. A missing or stale build is a normal convergence condition; rebuild it.
126
+
127
+ If build or required tests fail, report `LOCAL MODE NOT READY` and do not mark the mode ready.
128
+
129
+ When the project policy says application validation is local-only, GitHub publication/PR activity does not replace this local build/test evidence.
130
+
131
+ ### E. Local immutable Docker artifact
132
+
133
+ When the local runtime is Docker-based, build the application image locally using repository-owned commands. Resolve an immutable local image identity.
134
+
135
+ The standard Deployment Kit workstation adapter may accept either:
136
+
137
+ - an exact registry digest (`name@sha256:...`), or
138
+ - a local Docker image ID (`sha256:<64 hex>`) for workstation-only execution.
139
+
140
+ Never use a mutable tag as the final deployed identity. A local image ID is valid only for workstation placement and is not remote-release evidence.
141
+
142
+ ### F. Workstation runtime
143
+
144
+ Converge the local application runtime through the repository-distributed workstation adapter when available:
145
+
146
+ ```text
147
+ placement: workstation
148
+ isolation: container
149
+ exposure: loopback
150
+ ```
151
+
152
+ Keep exposure loopback-only. Mount the project-owned local SQLite persistence directory when required. Verify the declared health endpoint and any required local smoke check.
153
+
154
+ Do not resolve SSH targets, publish images, create public routes, or deploy Staging/Production while entering local mode.
155
+
156
+ ### G. Commit local mode only after readiness passes
157
+
158
+ Only after prerequisites, persistence, build, tests, runtime, and health requirements pass, write the workstation-local mode state as ready.
159
+
160
+ A successful summary should distinguish local evidence from remote evidence:
161
+
162
+ ```text
163
+ LOCAL DEVELOPER ENVIRONMENT READY
164
+
165
+ Build ........ READY
166
+ Tests ........ READY
167
+ Docker ....... READY
168
+ SQLite ....... READY / NOT APPLICABLE
169
+ Application .. HEALTHY
170
+ Remote deploy. DISABLED BY MODE
171
+ ```
172
+
173
+ ## 5. `remote` means verify remote-enabled capability, not deploy
174
+
175
+ Selecting `remote` authorizes bounded, non-destructive checks/setup needed to prove that the current workstation can work with the repository's configured remote non-Production environment(s). It does **not** publish an artifact or deploy an application.
176
+
177
+ Verify as applicable:
178
+
179
+ - normal local developer prerequisites still needed to build/test the project;
180
+ - repository deployment topology contains a non-deferred remote environment;
181
+ - exact target/provider binding exists when required;
182
+ - workstation-to-target trust/access is valid;
183
+ - target OS/runtime/platform readiness is valid;
184
+ - persistence/readiness prerequisites can be inspected safely;
185
+ - GitHub/registry access needed for a later release/publish step is available;
186
+ - repository release/deployment tooling and baselines are coherent.
187
+
188
+ For the standard remote-SSH path preserve strict host identity. Unknown or changed host identity is a stop condition. Never fall back to another environment or host.
189
+
190
+ Do not require a Production target merely to enter remote mode. Do not weaken Production authorization because remote mode is ready.
191
+
192
+ Only after the selected remote readiness checks pass, record `remote` as ready.
193
+
194
+ Then report the next explicit operation, typically:
195
+
196
+ ```text
197
+ /deployment-readiness <environment>
198
+ ```
199
+
200
+ A later `/deploy <environment>` remains separately gated.
201
+
202
+ ## 6. Fail closed on destructive or ambiguous repair
203
+
204
+ Automatic convergence may safely create missing local directories, dependency caches, build outputs, a new owned SQLite file, migrations that the project defines as normal forward initialization, and a new owned local application container.
205
+
206
+ Stop for a separate explicit decision before:
207
+
208
+ - deleting/resetting an existing database;
209
+ - destructive migration or rollback;
210
+ - replacing unexplained persistent data;
211
+ - deleting unrelated containers/images/volumes;
212
+ - publishing an artifact remotely;
213
+ - rebinding a target;
214
+ - weakening host verification;
215
+ - deploying Staging or Production;
216
+ - changing public/LAN exposure.
217
+
218
+ ## 7. Relationship to other Lean commands
219
+
220
+ `/developer-env-mode` prepares a workstation and establishes current operational readiness.
221
+
222
+ It does not replace:
223
+
224
+ - `/lean-update` for managed Lean package changes;
225
+ - project setup/doctor tooling;
226
+ - `/start-work` for implementation authorization;
227
+ - `/deployment-readiness` for environment-specific pre-deploy evidence;
228
+ - `/deploy` for deployment authorization/execution;
229
+ - `/tear-down` for destructive cleanup.
230
+
231
+ Mode switching never implies merge, release publication, Staging deployment, or Production deployment.
@@ -12,9 +12,13 @@ from .local_runtime import (
12
12
  apply_local_deploy,
13
13
  local_deploy_write_phrase,
14
14
  local_inventory,
15
- normalize_local_spec,
16
15
  plan_local_deploy,
17
16
  )
17
+ from .local_spec import normalize_workstation_spec
18
+ from .local_sqlite import ensure_local_sqlite, local_sqlite_status
19
+
20
+
21
+ LOCAL_SQLITE_ENSURE_WRITE_PHRASE = "[LOCAL SQLITE ENSURE]"
18
22
 
19
23
 
20
24
  def _print(value: Any, *, as_json: bool) -> None:
@@ -45,6 +49,14 @@ def _confirm(args: argparse.Namespace, phrase: str, action: str) -> None:
45
49
  raise DeploymentKitError(f"{action.capitalize()} was not authorized.")
46
50
 
47
51
 
52
+ def _add_sqlite_identity(parser: argparse.ArgumentParser) -> None:
53
+ parser.add_argument("--owner-repo", required=True)
54
+ parser.add_argument("--environment", required=True)
55
+ parser.add_argument("--app-slug", required=True)
56
+ parser.add_argument("--database-file", required=True)
57
+ parser.add_argument("--json", action="store_true")
58
+
59
+
48
60
  def build_parser() -> argparse.ArgumentParser:
49
61
  parser = argparse.ArgumentParser(prog="lean-poc-deploy local")
50
62
  sub = parser.add_subparsers(dest="command", required=True)
@@ -55,6 +67,19 @@ def build_parser() -> argparse.ArgumentParser:
55
67
  )
56
68
  inventory.add_argument("--json", action="store_true")
57
69
 
70
+ sqlite_status = sub.add_parser(
71
+ "sqlite-status",
72
+ help="Inspect one Lean-owned workstation SQLite database without writes",
73
+ )
74
+ _add_sqlite_identity(sqlite_status)
75
+
76
+ sqlite_ensure = sub.add_parser(
77
+ "sqlite-ensure",
78
+ help="Create a missing Lean-owned workstation SQLite database non-destructively",
79
+ )
80
+ _add_sqlite_identity(sqlite_ensure)
81
+ sqlite_ensure.add_argument("--yes", action="store_true")
82
+
58
83
  deploy = sub.add_parser(
59
84
  "deploy",
60
85
  help="Deploy one immutable application container bound only to loopback",
@@ -83,7 +108,50 @@ def main(argv: list[str] | None = None) -> int:
83
108
  _print(local_inventory(), as_json=args.json)
84
109
  return 0
85
110
 
86
- spec = normalize_local_spec(
111
+ if args.command == "sqlite-status":
112
+ _print(
113
+ local_sqlite_status(
114
+ owner_repo=args.owner_repo,
115
+ environment=args.environment,
116
+ app_slug=args.app_slug,
117
+ database_file=args.database_file,
118
+ ),
119
+ as_json=args.json,
120
+ )
121
+ return 0
122
+
123
+ if args.command == "sqlite-ensure":
124
+ status = local_sqlite_status(
125
+ owner_repo=args.owner_repo,
126
+ environment=args.environment,
127
+ app_slug=args.app_slug,
128
+ database_file=args.database_file,
129
+ )
130
+ if status["state"] == "ready":
131
+ _print(status, as_json=args.json)
132
+ return 0
133
+ if status["state"] == "unmanaged-existing":
134
+ raise DeploymentKitError(
135
+ "Refusing automatic adoption of an existing unmanaged local SQLite database."
136
+ )
137
+ _print(status, as_json=False)
138
+ _confirm(
139
+ args,
140
+ LOCAL_SQLITE_ENSURE_WRITE_PHRASE,
141
+ f"create local SQLite storage for {args.app_slug} ({args.environment})",
142
+ )
143
+ _print(
144
+ ensure_local_sqlite(
145
+ owner_repo=args.owner_repo,
146
+ environment=args.environment,
147
+ app_slug=args.app_slug,
148
+ database_file=args.database_file,
149
+ ),
150
+ as_json=args.json,
151
+ )
152
+ return 0
153
+
154
+ spec = normalize_workstation_spec(
87
155
  owner_repo=args.owner_repo,
88
156
  environment=args.environment,
89
157
  app_slug=args.app_slug,
@@ -0,0 +1,82 @@
1
+ """Workstation-only application spec normalization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ from typing import Any, Iterable
9
+
10
+ from .app_runtime import normalize_app_spec
11
+ from .errors import DeploymentKitError
12
+
13
+
14
+ _LOCAL_IMAGE_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
15
+
16
+
17
+ def _host_port(value: int) -> int:
18
+ if type(value) is not int or not 1 <= value <= 65535:
19
+ raise DeploymentKitError("hostPort must be an integer from 1 through 65535.")
20
+ return value
21
+
22
+
23
+ def _base_image(image: str) -> tuple[str, bool]:
24
+ if isinstance(image, str) and _LOCAL_IMAGE_ID_RE.fullmatch(image):
25
+ return f"local-workstation/immutable@{image}", True
26
+ return image, False
27
+
28
+
29
+ def normalize_workstation_spec(
30
+ *,
31
+ owner_repo: str,
32
+ environment: str,
33
+ app_slug: str,
34
+ container_name: str,
35
+ image: str,
36
+ runtime_port: int,
37
+ host_port: int,
38
+ health_path: str,
39
+ run_as: str,
40
+ persistence_mode: str,
41
+ data_container_path: str | None,
42
+ environment_variables: dict[str, str] | Iterable[tuple[str, str]],
43
+ ) -> dict[str, Any]:
44
+ """Normalize a loopback-only workstation spec.
45
+
46
+ Remote deployment keeps the registry-digest-only contract. Workstation execution
47
+ additionally accepts an immutable local Docker image ID (sha256:<64 hex>) so a
48
+ developer can build/test/run without publishing to a registry first.
49
+ """
50
+
51
+ normalized_image, local_image_id = _base_image(image)
52
+ base = normalize_app_spec(
53
+ owner_repo=owner_repo,
54
+ environment=environment,
55
+ app_slug=app_slug,
56
+ container_name=container_name,
57
+ image=normalized_image,
58
+ runtime_port=runtime_port,
59
+ health_path=health_path,
60
+ run_as=run_as,
61
+ persistence_mode=persistence_mode,
62
+ data_container_path=data_container_path,
63
+ environment_variables=environment_variables,
64
+ )
65
+ base.pop("specSha256", None)
66
+ if local_image_id:
67
+ base["image"] = image
68
+ base.update(
69
+ {
70
+ "schemaVersion": 1,
71
+ "placement": "workstation",
72
+ "isolation": "container",
73
+ "exposure": "loopback",
74
+ "hostAddress": "127.0.0.1",
75
+ "hostPort": _host_port(host_port),
76
+ "imageSource": "local-image-id" if local_image_id else "registry-digest",
77
+ }
78
+ )
79
+ base["specSha256"] = hashlib.sha256(
80
+ json.dumps(base, sort_keys=True, separators=(",", ":")).encode("utf-8")
81
+ ).hexdigest()
82
+ return base
@@ -0,0 +1,205 @@
1
+ """Non-destructive workstation SQLite lifecycle helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import sqlite3
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .errors import DeploymentKitError
12
+ from .profiles import config_root
13
+
14
+
15
+ _SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
16
+ _REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
17
+ _DB_FILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
18
+ _METADATA_FILE = "sqlite-local.json"
19
+
20
+
21
+ def _slug(value: str, field: str) -> str:
22
+ if not isinstance(value, str) or _SLUG_RE.fullmatch(value) is None:
23
+ raise DeploymentKitError(f"{field} must use lowercase letters, digits, and internal hyphens.")
24
+ return value
25
+
26
+
27
+ def _owner_repo(value: str) -> str:
28
+ if not isinstance(value, str) or _REPO_RE.fullmatch(value) is None:
29
+ raise DeploymentKitError("ownerRepo must use GitHub owner/repository form without URL syntax.")
30
+ return value
31
+
32
+
33
+ def _database_file(value: str) -> str:
34
+ if not isinstance(value, str) or _DB_FILE_RE.fullmatch(value) is None or value in {".", ".."}:
35
+ raise DeploymentKitError("databaseFile must be a safe file name without path separators.")
36
+ return value
37
+
38
+
39
+ def _state_root(app_slug: str, environment: str) -> Path:
40
+ return config_root() / "local-apps" / app_slug / environment
41
+
42
+
43
+ def _data_dir(app_slug: str, environment: str) -> Path:
44
+ return _state_root(app_slug, environment) / "data"
45
+
46
+
47
+ def _metadata_path(app_slug: str, environment: str) -> Path:
48
+ return _state_root(app_slug, environment) / _METADATA_FILE
49
+
50
+
51
+ def _database_path(app_slug: str, environment: str, database_file: str) -> Path:
52
+ return _data_dir(app_slug, environment) / database_file
53
+
54
+
55
+ def _load_metadata(app_slug: str, environment: str) -> dict[str, Any] | None:
56
+ path = _metadata_path(app_slug, environment)
57
+ if not path.is_file():
58
+ return None
59
+ try:
60
+ value = json.loads(path.read_text(encoding="utf-8"))
61
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
62
+ raise DeploymentKitError(f"Cannot read local SQLite metadata {path}: {error}") from error
63
+ if not isinstance(value, dict) or value.get("schemaVersion") != 1:
64
+ raise DeploymentKitError(f"Unsupported local SQLite metadata: {path}")
65
+ return value
66
+
67
+
68
+ def _write_metadata(path: Path, value: dict[str, Any]) -> None:
69
+ path.parent.mkdir(parents=True, exist_ok=True)
70
+ temporary = path.with_suffix(".tmp")
71
+ temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
72
+ temporary.replace(path)
73
+
74
+
75
+ def _quick_check(path: Path) -> str:
76
+ try:
77
+ with sqlite3.connect(path) as connection:
78
+ row = connection.execute("PRAGMA quick_check").fetchone()
79
+ except sqlite3.DatabaseError as error:
80
+ raise DeploymentKitError(f"Local SQLite database is not valid: {path}: {error}") from error
81
+ result = str(row[0]) if row else "missing-result"
82
+ if result.lower() != "ok":
83
+ raise DeploymentKitError(f"Local SQLite quick_check failed for {path}: {result}")
84
+ return result
85
+
86
+
87
+ def local_sqlite_status(
88
+ *, owner_repo: str, environment: str, app_slug: str, database_file: str
89
+ ) -> dict[str, Any]:
90
+ owner_repo = _owner_repo(owner_repo)
91
+ environment = _slug(environment, "environment")
92
+ app_slug = _slug(app_slug, "appSlug")
93
+ database_file = _database_file(database_file)
94
+ metadata = _load_metadata(app_slug, environment)
95
+ path = _database_path(app_slug, environment, database_file)
96
+
97
+ if metadata is not None:
98
+ expected = {
99
+ "ownerRepo": owner_repo,
100
+ "environment": environment,
101
+ "appSlug": app_slug,
102
+ "databaseFile": database_file,
103
+ }
104
+ for key, value in expected.items():
105
+ if metadata.get(key) != value:
106
+ raise DeploymentKitError(
107
+ f"Local SQLite metadata ownership mismatch for {app_slug}/{environment}: {key}."
108
+ )
109
+
110
+ if path.exists() and metadata is None:
111
+ return {
112
+ "schemaVersion": 1,
113
+ "ownerRepo": owner_repo,
114
+ "environment": environment,
115
+ "appSlug": app_slug,
116
+ "databaseFile": database_file,
117
+ "databasePath": str(path),
118
+ "state": "unmanaged-existing",
119
+ "managed": False,
120
+ "present": True,
121
+ "valid": False,
122
+ "mutated": False,
123
+ }
124
+
125
+ if not path.exists():
126
+ return {
127
+ "schemaVersion": 1,
128
+ "ownerRepo": owner_repo,
129
+ "environment": environment,
130
+ "appSlug": app_slug,
131
+ "databaseFile": database_file,
132
+ "databasePath": str(path),
133
+ "state": "missing",
134
+ "managed": metadata is not None,
135
+ "present": False,
136
+ "valid": False,
137
+ "mutated": False,
138
+ }
139
+
140
+ _quick_check(path)
141
+ return {
142
+ "schemaVersion": 1,
143
+ "ownerRepo": owner_repo,
144
+ "environment": environment,
145
+ "appSlug": app_slug,
146
+ "databaseFile": database_file,
147
+ "databasePath": str(path),
148
+ "state": "ready",
149
+ "managed": True,
150
+ "present": True,
151
+ "valid": True,
152
+ "bytes": path.stat().st_size,
153
+ "mutated": False,
154
+ }
155
+
156
+
157
+ def ensure_local_sqlite(
158
+ *, owner_repo: str, environment: str, app_slug: str, database_file: str
159
+ ) -> dict[str, Any]:
160
+ owner_repo = _owner_repo(owner_repo)
161
+ environment = _slug(environment, "environment")
162
+ app_slug = _slug(app_slug, "appSlug")
163
+ database_file = _database_file(database_file)
164
+
165
+ status = local_sqlite_status(
166
+ owner_repo=owner_repo,
167
+ environment=environment,
168
+ app_slug=app_slug,
169
+ database_file=database_file,
170
+ )
171
+ if status["state"] == "unmanaged-existing":
172
+ raise DeploymentKitError(
173
+ "Refusing to adopt an existing unmanaged local SQLite database automatically. "
174
+ "Review its ownership/data before using or moving it."
175
+ )
176
+ if status["state"] == "ready":
177
+ return status
178
+
179
+ path = _database_path(app_slug, environment, database_file)
180
+ path.parent.mkdir(parents=True, exist_ok=True)
181
+ try:
182
+ with sqlite3.connect(path) as connection:
183
+ connection.execute("PRAGMA user_version")
184
+ connection.commit()
185
+ except sqlite3.DatabaseError as error:
186
+ raise DeploymentKitError(f"Unable to create local SQLite database {path}: {error}") from error
187
+
188
+ metadata = {
189
+ "schemaVersion": 1,
190
+ "ownerRepo": owner_repo,
191
+ "environment": environment,
192
+ "appSlug": app_slug,
193
+ "databaseFile": database_file,
194
+ }
195
+ _write_metadata(_metadata_path(app_slug, environment), metadata)
196
+ _quick_check(path)
197
+ result = local_sqlite_status(
198
+ owner_repo=owner_repo,
199
+ environment=environment,
200
+ app_slug=app_slug,
201
+ database_file=database_file,
202
+ )
203
+ result["mutated"] = True
204
+ result["created"] = True
205
+ return result
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/lean",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "description": "Public consumer distribution and updater for Lean engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {