@mriekki/lean 0.4.5 → 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.5",
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": "68f3f45e663cab535c5a9544cf4efe2f048f8f34",
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.5",
4
+ "version": "0.4.7",
5
5
  "components": {
6
6
  "core": {
7
7
  "repository": "mriekki/lean-agentic-core",
8
- "commit": "68f3f45e663cab535c5a9544cf4efe2f048f8f34",
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",
@@ -192,7 +197,7 @@
192
197
  {
193
198
  "source": "core/process/PROJECT_DOCUMENTATION_FRAMEWORK.md",
194
199
  "destination": "docs/process/PROJECT_DOCUMENTATION_FRAMEWORK.md",
195
- "sha256": "e6697c8d9bc346e8c8229625fc422b869a6428a083e4fee717b97d17afde7b03"
200
+ "sha256": "8dc0773a245ed23089caa02a1a2f0dd5670710646d446d190406ae47a85da4c6"
196
201
  },
197
202
  {
198
203
  "source": "core/process/LEAN_ENGINEERING_GUIDE.md",
@@ -282,7 +287,7 @@
282
287
  {
283
288
  "source": "tools/docs_site/docs_site.py",
284
289
  "destination": "tools/lean-docs/docs-site.py",
285
- "sha256": "d8516175150b48b85b2a0b8013d5790b78bdd6eb6de73ab8dce9ed8e32e2dc68"
290
+ "sha256": "50ea30f5d42f71176a74a642123089c6e246f2da616e0c0bba2f16168d2c15c9"
286
291
  },
287
292
  {
288
293
  "source": "tools/package/check_baseline.py",
@@ -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",
@@ -190,6 +190,8 @@ Build-only mode:
190
190
  python .\tools\lean-docs\docs-site.py build --root . --out .\.lean\docs-site
191
191
  ```
192
192
 
193
+ The builder protects existing data: it refuses the repository root, paths overlapping either lab cache, linked paths, and non-empty output directories without its `.lean-docs-site` ownership marker. Existing unmarked output from an older viewer must be inspected and moved or a new output path selected before rebuilding.
194
+
193
195
  The viewer should:
194
196
 
195
197
  - bind to loopback only;
@@ -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.
@@ -13,13 +13,15 @@ import webbrowser
13
13
  from datetime import datetime, timezone
14
14
  from functools import partial
15
15
  from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
16
- from pathlib import Path
16
+ from pathlib import Path, PurePosixPath, PureWindowsPath
17
17
  from typing import Any
18
18
 
19
19
  IGNORE_DIRS = {".git", ".lean", "node_modules", "bin", "obj", "dist", "build", "target", ".venv", "venv", "__pycache__"}
20
20
  DEFAULT_CACHE = Path(".lean/docs-cache")
21
21
  DEFAULT_SITE = Path(".lean/docs-site")
22
22
  MANIFEST = Path("docs/lean-docs.json")
23
+ SITE_MARKER = ".lean-docs-site"
24
+ SITE_MARKER_CONTENT = "lean-docs-site\n"
23
25
 
24
26
 
25
27
  class DocsSiteError(RuntimeError):
@@ -86,17 +88,30 @@ def _manifest_documents(root: Path, manifest: dict[str, Any] | None) -> tuple[li
86
88
  if not isinstance(item, dict) or not isinstance(item.get("path"), str):
87
89
  warnings.append(f"docs/lean-docs.json documents[{index}] is invalid")
88
90
  continue
89
- rel = Path(item["path"]).as_posix().lstrip("/")
90
- if rel.startswith("../") or "/../" in rel:
91
+ raw_path = item["path"]
92
+ windows_path = PureWindowsPath(raw_path)
93
+ relative_path = PurePosixPath(raw_path.replace("\\", "/"))
94
+ if (
95
+ relative_path.is_absolute()
96
+ or windows_path.root
97
+ or windows_path.drive
98
+ or ".." in relative_path.parts
99
+ ):
91
100
  warnings.append(f"Skipped unsafe documentation path: {item['path']}")
92
101
  continue
102
+ rel = relative_path.as_posix()
93
103
  path = root / rel
104
+ try:
105
+ resolved_path = root / _safe_rel(root, path)
106
+ except DocsSiteError:
107
+ warnings.append(f"Skipped unsafe documentation path: {item['path']}")
108
+ continue
94
109
  seen.add(rel)
95
- if not path.is_file():
110
+ if not resolved_path.is_file():
96
111
  if item.get("optional") is not True:
97
112
  warnings.append(f"Listed documentation is not present: {rel}")
98
113
  continue
99
- content = path.read_text(encoding="utf-8", errors="replace")
114
+ content = resolved_path.read_text(encoding="utf-8", errors="replace")
100
115
  docs.append(
101
116
  {
102
117
  "path": rel,
@@ -116,14 +131,30 @@ def _manifest_documents(root: Path, manifest: dict[str, Any] | None) -> tuple[li
116
131
  candidates.append(path)
117
132
  docs_root = root / "docs"
118
133
  if docs_root.is_dir():
119
- for base, dirs, names in os.walk(docs_root):
120
- dirs[:] = [d for d in dirs if d not in IGNORE_DIRS]
121
- for name in names:
122
- if name.lower().endswith(".md"):
123
- candidates.append(Path(base) / name)
134
+ if _path_contains_link(docs_root):
135
+ warnings.append(f"Skipped unsafe documentation directory: {docs_root}")
136
+ else:
137
+ for base, dirs, names in os.walk(docs_root):
138
+ safe_dirs = []
139
+ for directory in dirs:
140
+ candidate = Path(base) / directory
141
+ if directory in IGNORE_DIRS:
142
+ continue
143
+ if _is_link(candidate):
144
+ warnings.append(f"Skipped unsafe documentation directory: {candidate}")
145
+ continue
146
+ safe_dirs.append(directory)
147
+ dirs[:] = safe_dirs
148
+ for name in names:
149
+ if name.lower().endswith(".md"):
150
+ candidates.append(Path(base) / name)
124
151
 
125
152
  for path in sorted(candidates):
126
- rel = _safe_rel(root, path)
153
+ try:
154
+ rel = _safe_rel(root, path)
155
+ except DocsSiteError:
156
+ warnings.append(f"Skipped unsafe documentation path: {path}")
157
+ continue
127
158
  if rel in seen:
128
159
  continue
129
160
  content = path.read_text(encoding="utf-8", errors="replace")
@@ -220,14 +251,113 @@ def _prepare_system_lab(root: Path) -> str | None:
220
251
  return None
221
252
 
222
253
 
223
- def _copy_lab_cache(root: Path, out: Path, kind: str) -> None:
254
+ def _validate_lab_cache(root: Path, kind: str) -> Path:
224
255
  source = root / DEFAULT_CACHE / kind
225
- if not source.is_dir():
226
- return
256
+ if _path_contains_link(source):
257
+ raise DocsSiteError(f"Lab cache contains a symlink or junction: {source}")
258
+ if source.exists():
259
+ try:
260
+ source.resolve().relative_to(root)
261
+ except ValueError as exc:
262
+ raise DocsSiteError(f"Lab cache escapes repository root: {source}") from exc
263
+ if source.is_dir() and _directory_contains_link(source):
264
+ raise DocsSiteError(f"Lab cache contains a symlink or junction: {source}")
265
+ return source
266
+
267
+
268
+ def _copy_lab_cache(root: Path, out: Path, kind: str) -> None:
269
+ source = _validate_lab_cache(root, kind)
227
270
  target = out / "labs" / kind
271
+ try:
272
+ target.resolve().relative_to(out)
273
+ except ValueError as exc:
274
+ raise DocsSiteError(f"Lab output escapes generated site directory: {target}") from exc
275
+ if _path_contains_link(target):
276
+ raise DocsSiteError(f"Lab output contains a symlink or junction: {target}")
228
277
  if target.exists():
278
+ if target.is_symlink() or not target.is_dir():
279
+ raise DocsSiteError(f"Unexpected lab output path: {target}")
229
280
  shutil.rmtree(target)
230
- shutil.copytree(source, target)
281
+ if source.is_dir():
282
+ shutil.copytree(source, target)
283
+
284
+
285
+ def _is_link(path: Path) -> bool:
286
+ if path.is_symlink():
287
+ return True
288
+ is_junction = getattr(path, "is_junction", None)
289
+ return bool(is_junction and is_junction())
290
+
291
+
292
+ def _path_contains_link(path: Path) -> bool:
293
+ absolute = Path(os.path.abspath(path))
294
+ current = Path(absolute.anchor)
295
+ for part in absolute.parts[1:]:
296
+ current /= part
297
+ if _is_link(current):
298
+ return True
299
+ return False
300
+
301
+
302
+ def _paths_overlap(left: Path, right: Path) -> bool:
303
+ left = Path(os.path.abspath(left))
304
+ right = Path(os.path.abspath(right))
305
+ return left == right or left in right.parents or right in left.parents
306
+
307
+
308
+ def _directory_contains_link(root: Path) -> bool:
309
+ directories = [root]
310
+ while directories:
311
+ current = directories.pop()
312
+ with os.scandir(current) as entries:
313
+ for entry in entries:
314
+ path = Path(entry.path)
315
+ if _is_link(path):
316
+ return True
317
+ if entry.is_dir(follow_symlinks=False):
318
+ directories.append(path)
319
+ return False
320
+
321
+
322
+ def _prepare_output(root: Path, out: Path) -> None:
323
+ if _path_contains_link(out):
324
+ raise DocsSiteError(f"Refusing to use an output path containing a symlink or junction: {out}")
325
+ if out == root or out in root.parents:
326
+ raise DocsSiteError(f"Refusing to use the repository root or one of its parents as output: {out}")
327
+ for kind in ("system", "database"):
328
+ cache = root / DEFAULT_CACHE / kind
329
+ if _paths_overlap(out, cache):
330
+ raise DocsSiteError(f"Output path overlaps a lab cache: {cache}")
331
+
332
+ if out.exists():
333
+ if not out.is_dir():
334
+ raise DocsSiteError(f"Output path is not a directory: {out}")
335
+ marker = out / SITE_MARKER
336
+ if any(out.iterdir()):
337
+ if marker.is_symlink() or not marker.is_file():
338
+ raise DocsSiteError(f"Refusing to overwrite an unowned output directory: {out}")
339
+ try:
340
+ marker_content = marker.read_text(encoding="utf-8")
341
+ except OSError as exc:
342
+ raise DocsSiteError(f"Unable to verify output ownership: {marker}") from exc
343
+ if marker_content != SITE_MARKER_CONTENT:
344
+ raise DocsSiteError(f"Refusing to overwrite an unowned output directory: {out}")
345
+
346
+ if _directory_contains_link(out):
347
+ raise DocsSiteError(f"Refusing to write through a symlink in output directory: {out}")
348
+
349
+ for name in (SITE_MARKER, "index.html", "site.json"):
350
+ path = out / name
351
+ if path.is_file():
352
+ try:
353
+ link_count = path.stat().st_nlink
354
+ except OSError as exc:
355
+ raise DocsSiteError(f"Unable to verify output file links: {path}") from exc
356
+ if link_count > 1:
357
+ raise DocsSiteError(f"Refusing to overwrite hard-linked output file: {path}")
358
+
359
+ out.mkdir(parents=True, exist_ok=True)
360
+ (out / SITE_MARKER).write_text(SITE_MARKER_CONTENT, encoding="utf-8")
231
361
 
232
362
 
233
363
  def _site_payload(root: Path) -> dict[str, Any]:
@@ -299,16 +429,16 @@ document.querySelector('#siteTitle').textContent=SITE.title;document.querySelect
299
429
 
300
430
  def build(root: Path, out: Path, *, prepare_system_lab: bool = False) -> tuple[Path, dict[str, Any]]:
301
431
  root = root.resolve()
302
- out = out.resolve()
432
+ out = Path(os.path.abspath(out))
303
433
  if not root.is_dir():
304
434
  raise DocsSiteError(f"Repository root does not exist: {root}")
435
+ for kind in ("system", "database"):
436
+ _validate_lab_cache(root, kind)
437
+ _prepare_output(root, out)
305
438
  prep_warning = _prepare_system_lab(root) if prepare_system_lab else None
306
439
  payload = _site_payload(root)
307
440
  if prep_warning:
308
441
  payload["warnings"].append(prep_warning)
309
- if out.exists():
310
- shutil.rmtree(out)
311
- out.mkdir(parents=True, exist_ok=True)
312
442
  _copy_lab_cache(root, out, "system")
313
443
  _copy_lab_cache(root, out, "database")
314
444
  index = out / "index.html"
@@ -359,7 +489,7 @@ def parse_args() -> argparse.Namespace:
359
489
  def main() -> int:
360
490
  args = parse_args()
361
491
  root = args.root.resolve()
362
- out = args.out.resolve() if args.out else (root / DEFAULT_SITE).resolve()
492
+ out = args.out if args.out else root / DEFAULT_SITE
363
493
  try:
364
494
  if args.command == "build":
365
495
  index, payload = build(root, out, prepare_system_lab=args.prepare_system_lab)
@@ -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.5",
3
+ "version": "0.4.7",
4
4
  "description": "Public consumer distribution and updater for Lean engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {