From 4b0b298e41c52c2cd3ec9eb1bcfaba72e4f6a57a Mon Sep 17 00:00:00 2001 From: Menglei Li <57990306+Lumos-789@users.noreply.github.com> Date: Mon, 9 Nov 2026 02:02:06 +0800 Subject: [PATCH] feat(galadon): add galadon plugin v0.3.4 --- assets/galadon/icon.png | Bin 0 -> 9941 bytes .../galadon/.codex-plugin/plugin.json | 33 + external_plugins/galadon/.release-gate.json | 9 + .../galadon/.zcode-plugin/plugin.json | 26 + external_plugins/galadon/CHANGELOG.md | 195 +++++ external_plugins/galadon/LICENSE | 21 + external_plugins/galadon/README.md | 220 +++++ external_plugins/galadon/README_CN.md | 199 +++++ .../galadon/assets/galadon/icon.png | Bin 0 -> 9941 bytes external_plugins/galadon/bin/galadon | 13 + external_plugins/galadon/cordis.patch.yml | 3 + .../galadon/examples/minimal/README.md | 14 + .../galadon/examples/minimal/galadon.yaml | 27 + .../galadon/examples/minimal/server.py | 52 ++ .../galadon/examples/minimal/spec/spec.md | 14 + .../examples/minimal/tests/test_health.py | 33 + external_plugins/galadon/index.js | 83 ++ .../galadon/schema/galadon.schema.json | 98 +++ .../galadon/skills/galadon/SKILL.md | 70 ++ .../galadon/src/galadon/__init__.py | 1 + .../galadon/src/galadon/__main__.py | 11 + .../galadon/src/galadon/adapters/__init__.py | 17 + .../galadon/src/galadon/adapters/local.py | 245 ++++++ .../galadon/src/galadon/adapters/probe.py | 61 ++ .../galadon/src/galadon/adapters/release.py | 164 ++++ .../galadon/src/galadon/adapters/ssh.py | 777 ++++++++++++++++++ external_plugins/galadon/src/galadon/cli.py | 402 +++++++++ .../galadon/src/galadon/config.py | 272 ++++++ external_plugins/galadon/src/galadon/gates.py | 149 ++++ .../galadon/src/galadon/layouts.py | 193 +++++ external_plugins/galadon/src/galadon/loop.py | 146 ++++ external_plugins/galadon/src/galadon/ports.py | 39 + external_plugins/galadon/src/galadon/roles.py | 47 ++ .../galadon/src/galadon/templates.py | 147 ++++ external_plugins/galadon/src/galadon/util.py | 115 +++ .../galadon/templates/base/.env.example | 2 + .../galadon/templates/base/AGENTS.md.tmpl | 41 + .../galadon/templates/base/README.md.tmpl | 18 + .../galadon/templates/base/deploy/README.md | 12 + .../galadon/templates/base/galadon.yaml.tmpl | 30 + .../galadon/templates/base/gitignore | 10 + .../galadon/templates/base/release/README.md | 4 + .../templates/base/scripts/quality-check.sh | 4 + .../galadon/templates/base/spec/spec.md.tmpl | 13 + .../galadon/templates/base/src/README.md | 3 + .../galadon/templates/base/verify/README.md | 4 + .../galadon/templates/base/wiki/README.md | 5 + .../galadon/templates/base/wiki/retro.md.tmpl | 13 + .../layouts/_shared/__agents__/README.md | 17 + .../_shared/__agents__/archive/.gitkeep | 0 .../_shared/__agents__/evidence/.gitkeep | 0 .../layouts/_shared/__agents__/plans/.gitkeep | 0 .../_shared/__agents__/skills/.gitkeep | 0 .../layouts/_shared/__agents__/tmp/.gitkeep | 0 .../layouts/_shared/__agents__/wiki/.gitkeep | 0 .../templates/layouts/_shared/gitignore | 11 + .../_shared/openspec/changes/README.md | 8 + .../layouts/_shared/openspec/config.yaml.tmpl | 15 + .../layouts/_shared/openspec/specs/README.md | 5 + .../layouts/_shared/scripts/README.md | 6 + .../layouts/_shared/spec/CONSTITUTION.md.tmpl | 49 ++ .../layouts/_shared/spec/INDEX.md.tmpl | 35 + .../layouts/_shared/spec/TESTING.md.tmpl | 24 + .../layouts/_shared/spec/domains/README.md | 40 + .../templates/layouts/_shared/wiki/README.md | 11 + .../layouts/_shared/wiki/knowledge/README.md | 9 + .../layouts/_shared/wiki/progress/README.md | 10 + .../layouts/_shared/wiki/runbook/README.md | 10 + .../layouts/plugin/common/AGENTS.md.tmpl | 68 ++ .../layouts/plugin/common/README.md.tmpl | 47 ++ .../layouts/plugin/common/config.md.tmpl | 29 + .../layouts/plugin/common/docs/packaging.md | 19 + .../templates/layouts/plugin/common/gitignore | 12 + .../plugin/common/openspec/config.yaml.tmpl | 16 + .../layouts/plugin/common/scripts/README.md | 6 + .../common/skills/{{NPM_NAME}}/SKILL.md.tmpl | 27 + .../layouts/plugin/common/spec/spec.md.tmpl | 19 + .../layouts/plugin/common/wiki/README.md | 11 + .../plugin/common/wiki/knowledge/README.md | 9 + .../plugin/common/wiki/progress/README.md | 10 + .../plugin/common/wiki/runbook/README.md | 10 + .../layouts/plugin/node/bin/{{NPM_NAME}}.tmpl | 4 + .../layouts/plugin/node/package.json.tmpl | 21 + .../layouts/plugin/node/scripts/dev.sh.tmpl | 4 + .../layouts/plugin/node/src/index.js.tmpl | 32 + .../plugin/node/tests/cli.test.js.tmpl | 24 + .../layouts/plugin/node/tests/index.js | 13 + .../plugin/python/bin/{{NPM_NAME}}.tmpl | 4 + .../layouts/plugin/python/scripts/dev.sh.tmpl | 4 + .../python/src/{{PACKAGE}}/__init__.py.tmpl | 5 + .../plugin/python/src/{{PACKAGE}}/__main__.py | 5 + .../plugin/python/src/{{PACKAGE}}/cli.py.tmpl | 31 + .../plugin/python/tests/test_cli.py.tmpl | 42 + .../layouts/service/common/AGENTS.md.tmpl | 65 ++ .../layouts/service/common/README.md.tmpl | 43 + .../layouts/service/common/config.md | 32 + .../layouts/service/common/deploy/README.md | 12 + .../layouts/service/common/spec/spec.md.tmpl | 18 + .../layouts/webapp/common/AGENTS.md.tmpl | 65 ++ .../layouts/webapp/common/README.md.tmpl | 44 + .../templates/layouts/webapp/common/config.md | 32 + .../layouts/webapp/common/deploy/README.md | 12 + .../layouts/webapp/common/e2e/README.md | 8 + .../layouts/webapp/common/spec/spec.md.tmpl | 18 + .../layouts/webapp/common/web/README.md | 8 + .../layouts/webapp/node/package.json.tmpl | 14 + .../layouts/webapp/node/scripts/dev.sh | 4 + .../layouts/webapp/node/server/index.js.tmpl | 39 + .../webapp/node/tests/health.test.js.tmpl | 25 + .../layouts/webapp/node/tests/index.js | 13 + .../layouts/webapp/python/scripts/dev.sh | 4 + .../layouts/webapp/python/server/app.py.tmpl | 13 + .../python/src/{{PACKAGE}}/__init__.py.tmpl | 5 + .../webapp/python/src/{{PACKAGE}}/app.py.tmpl | 48 ++ .../webapp/python/tests/test_app.py.tmpl | 41 + .../templates/profiles/api/docs/profile.md | 4 + .../templates/profiles/static/docs/profile.md | 4 + .../templates/profiles/web/docs/profile.md | 4 + marketplace.json | 90 +- 119 files changed, 5429 insertions(+), 13 deletions(-) create mode 100644 assets/galadon/icon.png create mode 100644 external_plugins/galadon/.codex-plugin/plugin.json create mode 100644 external_plugins/galadon/.release-gate.json create mode 100644 external_plugins/galadon/.zcode-plugin/plugin.json create mode 100644 external_plugins/galadon/CHANGELOG.md create mode 100644 external_plugins/galadon/LICENSE create mode 100644 external_plugins/galadon/README.md create mode 100644 external_plugins/galadon/README_CN.md create mode 100644 external_plugins/galadon/assets/galadon/icon.png create mode 100755 external_plugins/galadon/bin/galadon create mode 100644 external_plugins/galadon/cordis.patch.yml create mode 100644 external_plugins/galadon/examples/minimal/README.md create mode 100644 external_plugins/galadon/examples/minimal/galadon.yaml create mode 100644 external_plugins/galadon/examples/minimal/server.py create mode 100644 external_plugins/galadon/examples/minimal/spec/spec.md create mode 100644 external_plugins/galadon/examples/minimal/tests/test_health.py create mode 100644 external_plugins/galadon/index.js create mode 100644 external_plugins/galadon/schema/galadon.schema.json create mode 100644 external_plugins/galadon/skills/galadon/SKILL.md create mode 100644 external_plugins/galadon/src/galadon/__init__.py create mode 100644 external_plugins/galadon/src/galadon/__main__.py create mode 100644 external_plugins/galadon/src/galadon/adapters/__init__.py create mode 100644 external_plugins/galadon/src/galadon/adapters/local.py create mode 100644 external_plugins/galadon/src/galadon/adapters/probe.py create mode 100644 external_plugins/galadon/src/galadon/adapters/release.py create mode 100644 external_plugins/galadon/src/galadon/adapters/ssh.py create mode 100644 external_plugins/galadon/src/galadon/cli.py create mode 100644 external_plugins/galadon/src/galadon/config.py create mode 100644 external_plugins/galadon/src/galadon/gates.py create mode 100644 external_plugins/galadon/src/galadon/layouts.py create mode 100644 external_plugins/galadon/src/galadon/loop.py create mode 100644 external_plugins/galadon/src/galadon/ports.py create mode 100644 external_plugins/galadon/src/galadon/roles.py create mode 100644 external_plugins/galadon/src/galadon/templates.py create mode 100644 external_plugins/galadon/src/galadon/util.py create mode 100644 external_plugins/galadon/templates/base/.env.example create mode 100644 external_plugins/galadon/templates/base/AGENTS.md.tmpl create mode 100644 external_plugins/galadon/templates/base/README.md.tmpl create mode 100644 external_plugins/galadon/templates/base/deploy/README.md create mode 100644 external_plugins/galadon/templates/base/galadon.yaml.tmpl create mode 100644 external_plugins/galadon/templates/base/gitignore create mode 100644 external_plugins/galadon/templates/base/release/README.md create mode 100755 external_plugins/galadon/templates/base/scripts/quality-check.sh create mode 100644 external_plugins/galadon/templates/base/spec/spec.md.tmpl create mode 100644 external_plugins/galadon/templates/base/src/README.md create mode 100644 external_plugins/galadon/templates/base/verify/README.md create mode 100644 external_plugins/galadon/templates/base/wiki/README.md create mode 100644 external_plugins/galadon/templates/base/wiki/retro.md.tmpl create mode 100644 external_plugins/galadon/templates/layouts/_shared/__agents__/README.md create mode 100644 external_plugins/galadon/templates/layouts/_shared/__agents__/archive/.gitkeep create mode 100644 external_plugins/galadon/templates/layouts/_shared/__agents__/evidence/.gitkeep create mode 100644 external_plugins/galadon/templates/layouts/_shared/__agents__/plans/.gitkeep create mode 100644 external_plugins/galadon/templates/layouts/_shared/__agents__/skills/.gitkeep create mode 100644 external_plugins/galadon/templates/layouts/_shared/__agents__/tmp/.gitkeep create mode 100644 external_plugins/galadon/templates/layouts/_shared/__agents__/wiki/.gitkeep create mode 100644 external_plugins/galadon/templates/layouts/_shared/gitignore create mode 100644 external_plugins/galadon/templates/layouts/_shared/openspec/changes/README.md create mode 100644 external_plugins/galadon/templates/layouts/_shared/openspec/config.yaml.tmpl create mode 100644 external_plugins/galadon/templates/layouts/_shared/openspec/specs/README.md create mode 100644 external_plugins/galadon/templates/layouts/_shared/scripts/README.md create mode 100644 external_plugins/galadon/templates/layouts/_shared/spec/CONSTITUTION.md.tmpl create mode 100644 external_plugins/galadon/templates/layouts/_shared/spec/INDEX.md.tmpl create mode 100644 external_plugins/galadon/templates/layouts/_shared/spec/TESTING.md.tmpl create mode 100644 external_plugins/galadon/templates/layouts/_shared/spec/domains/README.md create mode 100644 external_plugins/galadon/templates/layouts/_shared/wiki/README.md create mode 100644 external_plugins/galadon/templates/layouts/_shared/wiki/knowledge/README.md create mode 100644 external_plugins/galadon/templates/layouts/_shared/wiki/progress/README.md create mode 100644 external_plugins/galadon/templates/layouts/_shared/wiki/runbook/README.md create mode 100644 external_plugins/galadon/templates/layouts/plugin/common/AGENTS.md.tmpl create mode 100644 external_plugins/galadon/templates/layouts/plugin/common/README.md.tmpl create mode 100644 external_plugins/galadon/templates/layouts/plugin/common/config.md.tmpl create mode 100644 external_plugins/galadon/templates/layouts/plugin/common/docs/packaging.md create mode 100644 external_plugins/galadon/templates/layouts/plugin/common/gitignore create mode 100644 external_plugins/galadon/templates/layouts/plugin/common/openspec/config.yaml.tmpl create mode 100644 external_plugins/galadon/templates/layouts/plugin/common/scripts/README.md create mode 100644 external_plugins/galadon/templates/layouts/plugin/common/skills/{{NPM_NAME}}/SKILL.md.tmpl create mode 100644 external_plugins/galadon/templates/layouts/plugin/common/spec/spec.md.tmpl create mode 100644 external_plugins/galadon/templates/layouts/plugin/common/wiki/README.md create mode 100644 external_plugins/galadon/templates/layouts/plugin/common/wiki/knowledge/README.md create mode 100644 external_plugins/galadon/templates/layouts/plugin/common/wiki/progress/README.md create mode 100644 external_plugins/galadon/templates/layouts/plugin/common/wiki/runbook/README.md create mode 100755 external_plugins/galadon/templates/layouts/plugin/node/bin/{{NPM_NAME}}.tmpl create mode 100644 external_plugins/galadon/templates/layouts/plugin/node/package.json.tmpl create mode 100755 external_plugins/galadon/templates/layouts/plugin/node/scripts/dev.sh.tmpl create mode 100644 external_plugins/galadon/templates/layouts/plugin/node/src/index.js.tmpl create mode 100644 external_plugins/galadon/templates/layouts/plugin/node/tests/cli.test.js.tmpl create mode 100644 external_plugins/galadon/templates/layouts/plugin/node/tests/index.js create mode 100755 external_plugins/galadon/templates/layouts/plugin/python/bin/{{NPM_NAME}}.tmpl create mode 100755 external_plugins/galadon/templates/layouts/plugin/python/scripts/dev.sh.tmpl create mode 100644 external_plugins/galadon/templates/layouts/plugin/python/src/{{PACKAGE}}/__init__.py.tmpl create mode 100644 external_plugins/galadon/templates/layouts/plugin/python/src/{{PACKAGE}}/__main__.py create mode 100644 external_plugins/galadon/templates/layouts/plugin/python/src/{{PACKAGE}}/cli.py.tmpl create mode 100644 external_plugins/galadon/templates/layouts/plugin/python/tests/test_cli.py.tmpl create mode 100644 external_plugins/galadon/templates/layouts/service/common/AGENTS.md.tmpl create mode 100644 external_plugins/galadon/templates/layouts/service/common/README.md.tmpl create mode 100644 external_plugins/galadon/templates/layouts/service/common/config.md create mode 100644 external_plugins/galadon/templates/layouts/service/common/deploy/README.md create mode 100644 external_plugins/galadon/templates/layouts/service/common/spec/spec.md.tmpl create mode 100644 external_plugins/galadon/templates/layouts/webapp/common/AGENTS.md.tmpl create mode 100644 external_plugins/galadon/templates/layouts/webapp/common/README.md.tmpl create mode 100644 external_plugins/galadon/templates/layouts/webapp/common/config.md create mode 100644 external_plugins/galadon/templates/layouts/webapp/common/deploy/README.md create mode 100644 external_plugins/galadon/templates/layouts/webapp/common/e2e/README.md create mode 100644 external_plugins/galadon/templates/layouts/webapp/common/spec/spec.md.tmpl create mode 100644 external_plugins/galadon/templates/layouts/webapp/common/web/README.md create mode 100644 external_plugins/galadon/templates/layouts/webapp/node/package.json.tmpl create mode 100755 external_plugins/galadon/templates/layouts/webapp/node/scripts/dev.sh create mode 100644 external_plugins/galadon/templates/layouts/webapp/node/server/index.js.tmpl create mode 100644 external_plugins/galadon/templates/layouts/webapp/node/tests/health.test.js.tmpl create mode 100644 external_plugins/galadon/templates/layouts/webapp/node/tests/index.js create mode 100755 external_plugins/galadon/templates/layouts/webapp/python/scripts/dev.sh create mode 100644 external_plugins/galadon/templates/layouts/webapp/python/server/app.py.tmpl create mode 100644 external_plugins/galadon/templates/layouts/webapp/python/src/{{PACKAGE}}/__init__.py.tmpl create mode 100644 external_plugins/galadon/templates/layouts/webapp/python/src/{{PACKAGE}}/app.py.tmpl create mode 100644 external_plugins/galadon/templates/layouts/webapp/python/tests/test_app.py.tmpl create mode 100644 external_plugins/galadon/templates/profiles/api/docs/profile.md create mode 100644 external_plugins/galadon/templates/profiles/static/docs/profile.md create mode 100644 external_plugins/galadon/templates/profiles/web/docs/profile.md diff --git a/assets/galadon/icon.png b/assets/galadon/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..bdfc8034dd65e885e4de1a0e4ad8c70d154568b2 GIT binary patch literal 9941 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%0Dh$2G! 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 literal 0 HcmV?d00001 diff --git a/external_plugins/galadon/.codex-plugin/plugin.json b/external_plugins/galadon/.codex-plugin/plugin.json new file mode 100644 index 00000000..9139ff2d --- /dev/null +++ b/external_plugins/galadon/.codex-plugin/plugin.json @@ -0,0 +1,33 @@ +{ + "name": "galadon", + "version": "0.3.4", + "description": "A five-stage engineering loop for agent-built projects: spec, build, verify, ship, learn, with a layout/stack scaffold generator. Pluggable role, template and deploy ports.", + "author": { + "name": "Lumos-789", + "url": "https://github.com/Lumos-789" + }, + "homepage": "https://github.com/Lumos-789/galadon", + "repository": "https://github.com/Lumos-789/galadon", + "license": "MIT", + "keywords": [ + "agent", + "workflow", + "scaffold", + "deploy", + "codex", + "zcode", + "dsh" + ], + "skills": "./skills/", + "interface": { + "displayName": "Galadon", + "shortDescription": "A five-stage engineering loop for agent-built projects, with a layout/stack scaffold generator", + "longDescription": "Galadon initializes an agent-ready engineering workspace with one stable loop: spec, build, verify, ship, learn, plus a layout/stack scaffold generator for webapp, service and plugin projects. Role policy, project templates and deployment adapters are pluggable ports; the bundled implementation is a reference, not the framework.", + "developerName": "Lumos-789", + "category": "Developer Tools", + "capabilities": [ + "Read", + "Write with confirmation" + ] + } +} diff --git a/external_plugins/galadon/.release-gate.json b/external_plugins/galadon/.release-gate.json new file mode 100644 index 00000000..638464be --- /dev/null +++ b/external_plugins/galadon/.release-gate.json @@ -0,0 +1,9 @@ +{ + "allow": [ + { + "rule": "sensitive-file", + "path": "templates/base/.env.example", + "reason": "Scaffold placeholder emitted by `galadon init`; it declares variable names with empty values and carries no credentials." + } + ] +} diff --git a/external_plugins/galadon/.zcode-plugin/plugin.json b/external_plugins/galadon/.zcode-plugin/plugin.json new file mode 100644 index 00000000..7962ce5e --- /dev/null +++ b/external_plugins/galadon/.zcode-plugin/plugin.json @@ -0,0 +1,26 @@ +{ + "name": "galadon", + "version": "0.3.4", + "description": "A five-stage engineering loop for agent-built projects: spec, build, verify, ship, learn, with a layout/stack scaffold generator. Pluggable role, template and deploy ports.", + "description_i18n": { + "en": "A five-stage engineering loop for agent-built projects: spec, build, verify, ship, learn, with a layout/stack scaffold generator. Pluggable role, template and deploy ports.", + "zh-CN": "面向 agent 工程的五阶段循环:规格、施工、验证、发布、收口;内置版式与技术栈的脚手架生成器;角色、模板与部署通过可替换端口接入。" + }, + "author": { + "name": "Lumos-789", + "url": "https://github.com/Lumos-789" + }, + "homepage": "https://github.com/Lumos-789/galadon", + "repository": "https://github.com/Lumos-789/galadon", + "license": "MIT", + "keywords": [ + "agent", + "workflow", + "scaffold", + "deploy", + "codex", + "zcode", + "dsh" + ], + "skills": "skills" +} diff --git a/external_plugins/galadon/CHANGELOG.md b/external_plugins/galadon/CHANGELOG.md new file mode 100644 index 00000000..72e4bbc0 --- /dev/null +++ b/external_plugins/galadon/CHANGELOG.md @@ -0,0 +1,195 @@ +# Changelog + +## 0.3.4 + +- Scaffold docs no longer point at things the scaffold does not generate. The + layout `AGENTS.md` templates sent reusable cross-project knowledge to "the + Guster hub" — a plugin the generated project does not have; the sentence now + names a knowledge hub outside the repo generically. The base `deploy/README` + told readers to implement `DeployPort` to connect SSH, but the `ssh` adapter + is built in (`deploy.adapter: ssh` in `galadon.yaml`); only Kubernetes still + needs a new adapter. READMEs, config notes, the plugin `AGENTS.md` and the + service spec acceptance item now say `scripts/dev.sh` is added by the + python/node stack layer, so a `--stack none` project no longer lists a + first command whose file was never generated. +- The shipped schema's `required` matches the loader: only `project` and + `template` are mandatory, and `deploy` no longer marks `adapter`/`env`. + `load_config` defaults `role` to `solo` and fills `deploy`/`checks` when the + blocks are absent, so the old list made the schema reject `galadon.yaml` + files the tool itself accepts. +- `.npmignore` is removed: `files` in `package.json` is an allowlist that + overrides it, so no line in it could affect the tarball; the bytecode defence + is the `prepack` script. +- `RolePolicy.assign(phase)` drops its unused `task` argument (no + implementation read it and the only caller passed an empty string), and + `extract_commit` reads the single `commit` field directly instead of looping + over a one-element tuple. +- The bundled example's health test no longer writes a `commit.txt` into a + temporary directory that nothing reads. + +## 0.3.3 + +- The deploy probe now waits for a service that was just restarted. + `systemctl restart` returns once the process is spawned, not once it is + listening: measured on a real host, the port refused connections for ~150 ms + afterwards. The probe fired immediately and failed, so every shipment of a + real project failed its probe and rolled back (2 out of 2 on the host used for + the relay dogfood). Connection-level failures are now retried inside a bounded + readiness window (15s); an HTTP answer is final and immediately subject to the + commit check, and a target that never answers still fails loudly, naming the + attempt count and the budget. The same zero tolerance made a rollback that had + actually restored service report itself as failed, because the post-rollback + re-check used the same probe. +- The probe fetch moved into `adapters/probe.py`, so the `local` and `ssh` + adapters share one readiness policy instead of carrying a copy each. +- `bootstrap` refuses a `remote_root` it does not own. It used to `install -d` + whatever path it was given and write its ownership marker into it, so a + directory with someone else's data became a galadon root; `teardown` then + deleted it, because the marker it checks was planted by that same bootstrap. + It now refuses a non-empty unmarked directory, and a root that carries another + slot's marker, before touching the user, the unit or Caddy. +- `teardown` no longer fails when the run user is shared. `userdel` on the + default `galadon` user fails while any other slot runs as it — which is the + normal state on a host with two deployments — and the teardown aborted + *after* removing the root. It now leaves the user in place, says so, and + reports `user_skip_reason`, while still failing loudly if the user cannot be + removed for any other reason. +- `ship` refuses to publish a dirty working tree. The commit stamped on the + release comes from `HEAD`, but the build copies the working tree, so + uncommitted changes shipped under a commit they are not part of and a later + rollback would restore a version that never existed. The loop's own files + (evidence, the release record, `.galadon/`, the phase block in `AGENTS.md`) + are exempt. +- `ship --dry-run` no longer demands `--yes-prod`. Previewing a production + release required the flag that authorises one; the plan now prints + `requires: --yes-prod` instead, and the guard still applies to a real run. + +## 0.3.2 + +- `pytest` no longer walks into `examples/`. Running it from the repo root used + to collect `examples/minimal/tests/test_health.py`, import the example's + `server.py`, and leave `__pycache__` inside the directory that ships in the + npm tarball. `files` is an allowlist and overrides `.npmignore`, so those + bytecode files ride into the package and turn the "npm tarball excludes + Python bytecode" test red for anyone who ran pytest before `node --test`. + `npm test` was unaffected because unittest discovery already stays in + `tests/`, which is why the failure surfaced only on the pytest path. The + example is still covered: it is exercised from a scratch copy. +- `prepack` deletes `__pycache__` and `*.pyc` before packing, so the tarball is + bytecode-free regardless of what ran in the tree beforehand. + +## 0.3.1 + +- `galadon.yaml` rejects unknown top-level keys instead of ignoring them. The + shipped schema already declared `additionalProperties: false`, so a block that + had stopped meaning anything — the retired 0.1.0 `tools:` port — could sit in + a real config doing nothing. The error names every unknown key and the allowed + set. +- The documented `check` behaviour with the default `none` layout now matches + the code: the first failure is the spec gate ("spec/spec.md still contains + TODO"), and "no checks configured" only appears once the spec is complete. + `README.md`, `README_CN.md` and `galadon init --list-layouts` had the checks + failure first. +- Tests: a standing matrix covers all 36 layout × stack × template + combinations, asserting each one is either green on a fresh scaffold or fails + loudly with the reason it is expected to produce. `examples/minimal` — shipped + in the npm tarball and previously covered by nothing — must pass `galadon + check` from a scratch copy. + +## 0.3.0 + +- `galadon init --layout {none,webapp,service,plugin}` (default `none`) and + `--stack {python,node,none}` (default `python`) generate the nine layers that + three unrelated real projects already share: entry, plan, truth, knowledge, + context, code, run, verify, output. `galadon init --list-layouts` prints the + shipped layouts and stacks. +- `layout=none` keeps 0.2.x behaviour: the base template only, the same + directories, and the same artifact paths. +- Real stack checks: `python3 -m compileall -f -q ` and + `python3 -m unittest discover -s tests -v`, or `node --check ` plus + `npm test` (`node --test tests/`). The plugin layout also runs + `bash -n bin/` on both stacks. Every layout ships passing tests, so + `galadon check` is genuinely green right after `init`; `--stack none` still + fails loudly with "no checks configured". +- `galadon check` enforces the layout contract for non-`none` layouts: + `AGENTS.md`, `openspec/config.yaml`, `spec/INDEX.md`, `spec/domains/`, + `spec/TESTING.md`, `spec/CONSTITUTION.md`, + `wiki/{knowledge,progress,runbook}/`, the six `.agents/` slots, non-empty + checks, and `out/` + `.agents/` in `.gitignore`. A missing item fails the gate + and is named. +- Every non-`none` layout scaffolds the spec truth layer under `spec/`: + `INDEX.md` (living feature index with `F--01` IDs, `1w`/`2w` gates + and `主落点` pointers), `domains/README.md` (behaviour truth and `impl` + pointers), `TESTING.md` (change-to-check trigger matrix) and + `CONSTITUTION.md` (hard rules). The files live once in + `templates/layouts/_shared/` and render before the layout's `common/` layer, + so a layout can override them; `none` is unchanged. +- New `paths:` block in `galadon.yaml` (`spec`, `evidence`, `retro`, `release`) + read by the loop instead of hardcoded constants. Defaults are the 0.2.x + paths; layouts put `retro` under `wiki/progress/`. +- Layout templates are organised as `templates/layouts//{common,python,node}`, + with the spec truth layer stored once in `templates/layouts/_shared/` and + rendered before each layout's `common/` layer. `.agents/` is stored as + `__agents__/` because that directory is gitignored in this repository, and the + renderer maps it back, substituting placeholders in paths as well as content. +- `webapp` and `service` shipped two byte-identical copies of the app skeleton + (`server/app.py`, `src//`, `tests/`, `scripts/dev.sh`, for both stacks — + 10 files, 206 lines). A fix to one would have left the other silently stale, + which is exactly the failure mode nobody notices until a user picks that + layout, so `service` now renders `webapp`'s stack templates through + `LayoutSpec.stack_templates`. Generated output is unchanged across all 36 + profile × layout × stack combinations, verified against a pre-change baseline. + +## 0.2.2 + +- The `local` DeployPort now uses the same release contract as `ssh`: immutable + `releases/-` directories under `deploy.options.root` + (default `.galadon/deploy`), an atomic `current` switch, a retained + `previous`, and an exclusive `shared/deploy.lock`. A held lock refuses the + ship with a non-zero exit instead of interleaving releases. +- A failed local probe rolls back to `previous`, re-probes the restored + release, and names the release that is live again. +- The local probe is exact: a JSON response must carry a `commit` field equal + to `deploy.probe_expect`, and a non-JSON response must equal it exactly. The + recorded `actual_commit` now comes from the probe response, not build + metadata. +- `galadon init` no longer scaffolds the do-nothing `checks: {build: "true", + test: "true"}` gate. Empty checks make `galadon check` fail loudly with + "no checks configured — declare checks.build/checks.test" until real commands + are declared. +- `npm pack` no longer ships `__pycache__/` or `*.pyc` files from the working + tree. + +## 0.2.1 + +- The `local` DeployPort hashes an artifact directory the same way the `ssh` + adapter does, so `artifact: dist` works with either adapter; an unreadable + artifact reports a `galadon:` error instead of a Python traceback. +- An invalid `galadon.yaml` reports the parser message instead of a Python + traceback. +- The `local` DeployPort requires `deploy.probe_expect`: a bare HTTP 200 is not + a version probe, so it no longer counts as a successful ship. +- A missing PyYAML reports `galadon: PyYAML is required (pip install pyyaml)` + and exits 69 instead of an import traceback. + +## 0.2.0 + +- `ssh` DeployPort: release directories, atomic `current`/`previous` switch, + remote deploy lock, commit-versioned JSON probe and automatic rollback. +- `bootstrap` / `teardown` are part of `DeployPort`; the CLI no longer reaches + into adapter internals. +- `teardown` refuses a remote root that `bootstrap` did not create. +- `ship` fails before deploying when the installed systemd unit drifted from + `galadon.yaml`. +- `galadon new` archives a finished loop and returns to `build`. +- Remote paths are charset-checked before reaching a shell or a unit file. +- Added a runnable `examples/minimal` workspace. + +## 0.1.0 + +- Five-stage loop (`spec`, `build`, `verify`, `ship`, `learn`) with one artifact + and one gate per phase. +- `RolePort` presets (`solo`, `six-ring`, `orchestrator`), `TemplatePort` + profiles (`web`, `api`, `static`), local `DeployPort` reference and `ToolPort` + interfaces. +- `init`, `next`, `check`, `ship` and the managed `AGENTS.md` block. diff --git a/external_plugins/galadon/LICENSE b/external_plugins/galadon/LICENSE new file mode 100644 index 00000000..b6c14d9c --- /dev/null +++ b/external_plugins/galadon/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Lumos-789 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/external_plugins/galadon/README.md b/external_plugins/galadon/README.md new file mode 100644 index 00000000..38998e74 --- /dev/null +++ b/external_plugins/galadon/README.md @@ -0,0 +1,220 @@ +# Galadon + +A five-stage engineering loop for agent-built projects. + +```text +Spec -> Build -> Verify -> Ship -> Learn +``` + +Galadon initializes a small engineering workspace, then keeps the agent on one +phase at a time. The core knows only phases, artifacts and gates. Everything +else is a port: + +| Port | Purpose | Reference implementation | +|---|---|---| +| `RolePort` | Who does the work | `solo` | +| `TemplatePort` | Which `docs/profile.md` to write; the code skeleton comes from `--layout`/`--stack` | `web`, `api`, `static` | +| `DeployPort` | How to build, migrate, release, probe and roll back | `local`, `ssh` | + +The bundled implementations are examples. A project can replace any port +without changing the five-stage loop. + +## Install + +The plugin ships the skill and the CLI together; no separate install step is +needed. + +```bash +# DeepSeek Harness +dsh plugin --profile headless add + +# ZCode +zcode plugins install galadon@ + +# Codex +codex plugin add galadon@ +``` + +Inside a workspace the agent runs the bundled `bin/galadon`. To use the CLI +yourself, either call `bin/galadon` from the plugin directory or install it: + +```bash +pip install # or: pipx install +``` + +Requires Python 3.10+ and `PyYAML`. + +## Quick start + +```bash +galadon init --template api --role solo # empty directory, or --force to add files +galadon next # one phase, one next action +``` + +Then let the agent drive: it reads `galadon.yaml`, does the current phase, and +runs `galadon check` until the loop reaches `learn`. With the default `none` +layout the first gate is the spec: `init` writes a `spec/spec.md` stub full of +TODO, so `check` fails with "spec/spec.md still contains TODO" until you write +it. `init` also leaves `checks.build` and `checks.test` empty on purpose, so +once the spec is done `check` fails loudly with "no checks configured — declare +checks.build/checks.test" until you declare the real commands. A non-`none` +layout ships a filled-in spec and real checks instead (see Layouts below). + +## Layouts + +`init --layout` adds the inside-project layering shared by the production +projects this scaffold was sampled from. The default +`none` keeps 0.2.x behaviour: base template only, no layout contract. + +| Layout | Use it for | Creates | +|---|---|---| +| `none` (default) | a bare Galadon loop | base template only | +| `webapp` | a product with a backend and a frontend | `server/`, `src/` (python stack), `web/`, `e2e/`, `tests/`, `scripts/`, `deploy/`, `openspec/`, `spec/{spec.md,INDEX.md,TESTING.md,CONSTITUTION.md,domains/}`, `wiki/{knowledge,progress,runbook}/`, `.agents/`, `config.md` | +| `service` | a backend service or scheduled job | `server/`, `src/` (python stack), `tests/`, `scripts/`, `deploy/`, `openspec/`, `spec/{spec.md,INDEX.md,TESTING.md,CONSTITUTION.md,domains/}`, `wiki/{knowledge,progress,runbook}/`, `.agents/`, `config.md` | +| `plugin` | a distributable agent plugin | `bin/`, `src//`, `skills//`, `docs/`, `tests/`, `scripts/`, `openspec/`, `spec/{spec.md,INDEX.md,TESTING.md,CONSTITUTION.md,domains/}`, `wiki/{knowledge,progress,runbook}/`, `.agents/`, `config.md` | + +The `python` stack adds a `src//` code root; the `node` stack puts the +entry in `server/index.js` (`webapp`, `service`) or `src/index.js` (`plugin`), so +`webapp` and `service` have no `src/` on `node`. + +Every non-`none` layout also carries the spec truth layer under `spec/`: +`INDEX.md` (the living feature index), `domains/README.md` (one behaviour +contract per domain), `TESTING.md` (the change-to-check trigger matrix), and +`CONSTITUTION.md` (the hard rules). These four files live once in +`templates/layouts/_shared/` and render before the layout's own `common/` +layer, so a layout can override a file while `none` stays unchanged. The files +every layout shares byte for byte (the `.agents/` slots and READMEs, the +`wiki/`, `openspec/` and `scripts/` READMEs, `.gitignore` and +`openspec/config.yaml`) live there once too; `plugin` keeps its own versions of +the ones that differ. + +`--stack` picks the code skeleton and the real checks: + +| Stack | build | test | +|---|---|---| +| `python` (default) | `python3 -m compileall -f -q src server`; plugin: `src` + `bash -n bin/` | `python3 -m unittest discover -s tests -v` | +| `node` | `node --check `; plugin: + `bash -n bin/` | `npm test` (`node --test tests/`) | +| `none` | — | — | + +A non-`none` layout with `--stack python|node` ships at least one real test, so +`galadon check` is green right after `init`; `--stack none` has no checks and +fails loudly. The build compiles or syntax-checks every source root the +layout ships and runs `bash -n` on the plugin's bash wrapper for both stacks, so +a broken entry point fails the gate instead of passing it. It also enforces the +layout contract: `AGENTS.md`, `openspec/config.yaml`, `spec/INDEX.md`, +`spec/domains/`, `spec/TESTING.md`, `spec/CONSTITUTION.md`, the three `wiki/` +directories, the six `.agents/` slots, non-empty checks, and `out/` + +`.agents/` in `.gitignore`. A missing item fails the gate and is named. +`.agents/` is stored in the templates as `__agents__/` because `.agents/` is +gitignored in this repository; the renderer maps it back during render. + +The `paths:` block in `galadon.yaml` moves the stage artifacts (`spec`, +`evidence`, `retro`, `release`). Defaults are the 0.2.x paths; layouts put the +retro in `wiki/progress/retro.md` because a retro is short-term progress, not +durable knowledge. + +```bash +galadon init --layout webapp --stack node --template web +galadon init --list-layouts # stable text: layouts and stacks +``` + +## Commands + +```bash +galadon init --template web --role solo +galadon init --layout webapp --stack python --template web +galadon next +galadon check +galadon new +galadon bootstrap --env test +galadon ship --env test +galadon teardown --env test --yes +``` + +`ship` refuses to report success without a real probe. Production releases +require `--yes-prod` in the current session. + +## Remote deploys + +`deploy.adapter: ssh` ships a directory to a host that runs systemd and a +reverse proxy, with releases, an atomic `current`/`previous` switch, a deploy +lock and a commit-versioned JSON probe: + +```yaml +deploy: + adapter: ssh + env: test + build: "mkdir -p dist && cp server.py dist/ && git rev-parse --short HEAD > dist/commit.txt" + probe_url: "https://demo.example.com/health.json" + probe_expect: "{{COMMIT}}" + artifact: "dist" + options: + host: 203.0.113.10 + user: root + remote_root: /srv/demo + service: demo + domain: demo.example.com + listen: 127.0.0.1:18790 + exec_start: "/usr/bin/python3 /srv/demo/current/server.py" + caddy_block: demo +``` + +`bootstrap` creates the system user, release tree, systemd unit and a marked +reverse-proxy block (it backs the proxy config up and validates before +reloading). `teardown --yes` only removes a root that still carries the +`bootstrap` marker. + +`deploy.adapter: local` uses the same release tree under +`deploy.options.root` (default `.galadon/deploy`): +`releases/-`, an atomic `current` switch, a retained +`previous`, and `shared/deploy.lock`. A held lock refuses the ship; a failed +probe restores `current` to `previous`, re-probes that release and exits +non-zero with the live release named. + +## Side effects + +Read this before pointing it at a machine you care about. + +- `bootstrap` writes on the remote host: a system user (unless + `options.manage_user: false`), `/{releases,shared}`, a systemd + unit, and one marked block in the reverse-proxy config. The proxy config is + backed up first and validated before any reload; a failed validation restores + it and does not reload. +- `ship` writes an immutable release, switches `current` atomically and keeps + `previous`; the `ssh` adapter also restarts the service. It then probes the + release. A failed probe rolls back to `previous`, re-probes it and exits + non-zero. +- `teardown --yes` removes the unit, the marked proxy block, the release tree + and the system user, but only if the root still carries the `bootstrap` + marker. +- Every remote write prints its target first. `--env prod` additionally + requires `--yes-prod` in the same invocation. + +## Limits + +- `DeployPort` implementations: `local` and `ssh` (systemd + Caddy). `k8s` is a + declared port with no implementation; calling it fails loudly. +- The `ssh` adapter assumes root access (or an account that can write the unit + and the proxy config), `rsync`, `flock` and `systemctl` on the target. +- The proxy block uses a Caddy snippet named `access_log`; hosts without that + snippet need their own `DeployPort` implementation. +- The `ssh` adapter requires probes to return JSON with a `commit` field that + matches the build; the `local` adapter requires `deploy.probe_expect` and + compares it exactly: a JSON body must carry a `commit` field equal to that + value, and a non-JSON body must equal it exactly. + +## Design rules + +- One phase has one artifact and one gate. +- The core never imports SSH, Kubernetes, Jira or a notification SDK. +- The six-ring workflow is a `RolePort` preset, not the core. +- Git content cannot authorize a production release. +- Fail loud; no silent fallback and no partial-success claim. + +## Development + +```bash +PYTHONPATH=src python3 -m galadon --help +python3 -m unittest discover -s tests -v +node --test tests/test_plugin.mjs +``` diff --git a/external_plugins/galadon/README_CN.md b/external_plugins/galadon/README_CN.md new file mode 100644 index 00000000..61fc0299 --- /dev/null +++ b/external_plugins/galadon/README_CN.md @@ -0,0 +1,199 @@ +# Galadon + +面向 agent 工程的五阶段循环: + +```text +Spec -> Build -> Verify -> Ship -> Learn +``` + +Galadon 初始化一个最小工程阵地,然后让 agent 每次只做当前阶段。内核只认阶段、 +产物和门禁;其余全部是端口: + +| 端口 | 作用 | 参考实现 | +|---|---|---| +| `RolePort` | 谁来做 | `solo` | +| `TemplatePort` | 写哪份 `docs/profile.md`;代码骨架由 `--layout`/`--stack` 决定 | `web`、`api`、`static` | +| `DeployPort` | 构建、迁移、发布、探活、回滚 | `local`、`ssh` | + +内置实现只是例子。项目可以替换任意端口,不需要改五阶段循环。 + +## 安装 + +插件自带 skill 和 CLI,装插件即可,不需要额外步骤。 + +```bash +# DeepSeek Harness +dsh plugin --profile headless add <路径或仓库> + +# ZCode +zcode plugins install galadon@ + +# Codex +codex plugin add galadon@ +``` + +工作区里 agent 直接用插件自带的 `bin/galadon`。自己想用 CLI 时,可以调 +插件目录下的 `bin/galadon`,或: + +```bash +pip install <插件目录> # 或 pipx install <插件目录> +``` + +依赖 Python 3.10+ 与 `PyYAML`。 + +## 快速开始 + +```bash +galadon init --template api --role solo # 空目录;非空目录加 --force +galadon next # 当前阶段 + 一个下一步 +``` + +之后交给 agent:它读 `galadon.yaml`,只做当前阶段,跑 `galadon check`, +直到循环走到 `learn`。默认 `none` 版式下,第一道门是 spec:`init` 写入的 +`spec/spec.md` 是 TODO 存根,所以在写完它之前 `check` 报 +"spec/spec.md still contains TODO"。`init` 同时故意把 `checks.build` 与 +`checks.test` 留空,所以 spec 写完后 `check` 会响亮失败并提示 +"no checks configured — declare checks.build/checks.test",直到声明真实命令; +非 `none` 版式则自带填好的 spec 与真实检查命令(见下文)。 + +## 版式(Layouts) + +`init --layout` 补的是「一个工程内部怎么分层」:这套层从三个互不相关的真实 +生产项目里抽出,默认 `none` 保持 0.2.x 行为:只生成基础模板,不做版式契约校验。 + +| 版式 | 适用 | 生成 | +|---|---|---| +| `none`(默认) | 只要五阶段循环 | 基础模板 | +| `webapp` | 前后端一体的产品 | `server/`、`src/`(python 栈)、`web/`、`e2e/`、`tests/`、`scripts/`、`deploy/`、`openspec/`、`spec/{spec.md,INDEX.md,TESTING.md,CONSTITUTION.md,domains/}`、`wiki/{knowledge,progress,runbook}/`、`.agents/`、`config.md` | +| `service` | 纯后端或定时任务 | `server/`、`src/`(python 栈)、`tests/`、`scripts/`、`deploy/`、`openspec/`、`spec/{spec.md,INDEX.md,TESTING.md,CONSTITUTION.md,domains/}`、`wiki/{knowledge,progress,runbook}/`、`.agents/`、`config.md` | +| `plugin` | 可分发 agent 插件 | `bin/`、`src//`、`skills//`、`docs/`、`tests/`、`scripts/`、`openspec/`、`spec/{spec.md,INDEX.md,TESTING.md,CONSTITUTION.md,domains/}`、`wiki/{knowledge,progress,runbook}/`、`.agents/`、`config.md` | + +`python` 栈另外生成 `src//` 代码根;`node` 栈把入口放在 +`server/index.js`(`webapp`、`service`)或 `src/index.js`(`plugin`),所以 +`webapp`、`service` 在 `node` 栈下没有 `src/`。 + +每个非 `none` 版式还带 `spec/` 真值层:`INDEX.md`(活的功能索引)、 +`domains/README.md`(每个领域一份行为契约)、`TESTING.md`(改动到检查的触发 +矩阵)、`CONSTITUTION.md`(硬规则)。这四个文件只在 +`templates/layouts/_shared/` 存一份,先于各版式自己的 `common/` 层渲染,所以 +版式可以覆盖,而 `none` 保持不变。跨版式逐字节相同的文件也只在 `_shared/` 存 +一份(`.agents/` 槽位与 README、`wiki/`、`openspec/`、`scripts/` 的 README、 +`.gitignore`、`openspec/config.yaml`);不同的那几个由 `plugin` 保留自己的版本。 + +`--stack` 决定代码骨架与真实检查命令: + +| 栈 | build | test | +|---|---|---| +| `python`(默认) | `python3 -m compileall -f -q src server`;plugin 为 `src` + `bash -n bin/` | `python3 -m unittest discover -s tests -v` | +| `node` | `node --check <版式入口>`;plugin 为 + `bash -n bin/` | `npm test`(`node --test tests/`) | +| `none` | — | — | + +非 `none` 版式配 `--stack python|node` 时自带至少一个真跑的测试,`init` 完 +`galadon check` 就是绿的;`--stack none` 没有检查命令,会响亮失败; +build 会编译/语法校验版式交付的每个源码根目录,并对 plugin 的 bash wrapper +用 `bash -n`(两种栈都覆盖),入口文件写坏会直接失败而不是假绿。 +同时校验目录契约:`AGENTS.md`、`openspec/config.yaml`、`spec/INDEX.md`、 +`spec/domains/`、`spec/TESTING.md`、`spec/CONSTITUTION.md`、`wiki/` 三个目录、 +`.agents/` 六个槽位、非空 checks、`.gitignore` 里的 `out/` 与 `.agents/`—— +缺项直接失败并点名。模板里 `.agents/` 存成 `__agents__/`(本仓库 gitignore 了 +`.agents/`),渲染时映射回来。 + +`galadon.yaml` 的 `paths:` 迁移阶段产物(`spec`、`evidence`、`retro`、`release`)。 +缺省值与 0.2.x 完全一致;版式把 retro 放到 `wiki/progress/retro.md`,因为 retro +属于短期进度,不属于长期知识层。 + +```bash +galadon init --layout webapp --stack node --template web +galadon init --list-layouts # 稳定输出:版式与栈 +``` + +## 命令 + +```bash +galadon init --template web --role solo +galadon init --layout webapp --stack python --template web +galadon next +galadon check +galadon new +galadon bootstrap --env test +galadon ship --env test +galadon teardown --env test --yes +``` + +`ship` 没有真实探针就不算成功。生产发布必须在当前会话显式加 `--yes-prod`。 +`new` 把上一轮的 checks 与 release 记录归档到 `.galadon/history/`,回到 `build` +开始下一轮。 + +## 远程部署 + +`deploy.adapter: ssh` 把产物发到一台跑 systemd + 反向代理的机器:多版本目录、 +原子切换 `current`/`previous`、部署锁、按 commit 校验的 JSON 探针。 + +```yaml +deploy: + adapter: ssh + env: test + build: "mkdir -p dist && cp server.py dist/ && git rev-parse --short HEAD > dist/commit.txt" + probe_url: "https://demo.example.com/health.json" + probe_expect: "{{COMMIT}}" + artifact: "dist" + options: + host: 203.0.113.10 + user: root + remote_root: /srv/demo + service: demo + domain: demo.example.com + listen: 127.0.0.1:18790 + exec_start: "/usr/bin/python3 /srv/demo/current/server.py" + caddy_block: demo +``` + +`bootstrap` 负责系统用户、发布目录、systemd unit 和带 marker 的反代 block +(改前备份、`caddy validate` 失败即恢复)。`teardown --yes` 只删除还带 +bootstrap marker 的远端目录。 + +`deploy.adapter: local` 使用同一套发布树,根目录为 `deploy.options.root` +(默认 `.galadon/deploy`):`releases/<时间戳>-<版本>`、原子切换 `current`、 +保留 `previous`、独占 `shared/deploy.lock`。锁被占用时拒绝发布;探针失败时 +把 `current` 恢复到 `previous` 并重新探针,然后以非零退出并指明当前在线的版本。 + +## 副作用 + +指向重要机器之前请先读这一段。 + +- `bootstrap` 会在远端写入:系统用户(`options.manage_user: false` 可关闭)、 + `/{releases,shared}`、systemd unit,以及反代配置里一个带 marker 的 + block。改反代配置前先备份,`caddy validate` 不过就恢复且不 reload。 +- `ship` 写入不可变发布目录、原子切换 `current` 并保留 `previous`;`ssh` + 适配器还会重启服务。随后探活;探活失败自动回滚到 `previous`、重新探针并以 + 非零退出。 +- `teardown --yes` 删除 unit、marker block、发布目录和系统用户;远端目录必须 + 仍带 bootstrap 写的 marker 才会执行。 +- 所有远端写操作先打印目标;`--env prod` 还必须同一次调用带 `--yes-prod`。 + +## 边界 + +- `DeployPort` 目前只有 `local` 与 `ssh`(systemd + Caddy)。`k8s` 只有声明, + 调用即 fail-loud。 +- `ssh` 适配器假定目标机有 root 权限、`rsync`、`flock`、`systemctl`。 +- 反代块依赖名为 `access_log` 的 Caddy snippet;没有这个 snippet 的机器需要自己 + 实现一个 `DeployPort`。 +- `ssh` 适配器要求探针返回带 `commit` 字段的 JSON,且与本次构建一致;`local` + 适配器要求配置 `deploy.probe_expect` 并精确比较:JSON 响应体里的 `commit` + 字段必须与之相等,非 JSON 响应体必须与之完全相等。 + +## 设计约束 + +- 一个阶段只有一个产物和一个门。 +- 内核不 import SSH、Kubernetes、Jira 或通知 SDK。 +- 六环是 `RolePort` 的预设,不是内核。 +- git 内容不能给生产发布授权。 +- 失败必须响亮;不静默兜底,不把部分成功说成成功。 + +## 开发 + +```bash +PYTHONPATH=src python3 -m galadon --help +python3 -m unittest discover -s tests -v +node --test tests/test_plugin.mjs +``` diff --git a/external_plugins/galadon/assets/galadon/icon.png b/external_plugins/galadon/assets/galadon/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..bdfc8034dd65e885e4de1a0e4ad8c70d154568b2 GIT binary patch literal 9941 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%0Dh$2G! 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 literal 0 HcmV?d00001 diff --git a/external_plugins/galadon/bin/galadon b/external_plugins/galadon/bin/galadon new file mode 100755 index 00000000..81b3e443 --- /dev/null +++ b/external_plugins/galadon/bin/galadon @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail +SOURCE="${BASH_SOURCE[0]}" +while [ -L "$SOURCE" ]; do + DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)" + SOURCE="$(readlink "$SOURCE")" + case "$SOURCE" in + /*) ;; + *) SOURCE="$DIR/$SOURCE" ;; + esac +done +HERE="$(cd -P "$(dirname "$SOURCE")/.." && pwd)" +PYTHONPATH="$HERE/src${PYTHONPATH:+:$PYTHONPATH}" exec python3 -m galadon "$@" diff --git a/external_plugins/galadon/cordis.patch.yml b/external_plugins/galadon/cordis.patch.yml new file mode 100644 index 00000000..be26eb6c --- /dev/null +++ b/external_plugins/galadon/cordis.patch.yml @@ -0,0 +1,3 @@ +- insert: + - id: galadon + name: 'galadon' diff --git a/external_plugins/galadon/examples/minimal/README.md b/external_plugins/galadon/examples/minimal/README.md new file mode 100644 index 00000000..4b85ed53 --- /dev/null +++ b/external_plugins/galadon/examples/minimal/README.md @@ -0,0 +1,14 @@ +# Minimal example + +A complete Galadon workspace that fits in five files. Copy this directory, +replace the `deploy.options` values, and the loop works end to end. + +```bash +cd examples/minimal +../../bin/galadon next +../../bin/galadon check +../../bin/galadon ship --env test --dry-run +``` + +`server.py` serves `GET /health.json` with the commit recorded in +`commit.txt`, which is what the probe checks after a deploy. diff --git a/external_plugins/galadon/examples/minimal/galadon.yaml b/external_plugins/galadon/examples/minimal/galadon.yaml new file mode 100644 index 00000000..441a89e4 --- /dev/null +++ b/external_plugins/galadon/examples/minimal/galadon.yaml @@ -0,0 +1,27 @@ +project: minimal +template: api +role: solo + +deploy: + adapter: ssh + env: test + build: "mkdir -p dist && cp server.py dist/ && git rev-parse --short HEAD > dist/commit.txt" + migrate: "" + release: "" + probe_url: "https://minimal.example.com/health.json" + probe_expect: "{{COMMIT}}" + artifact: "dist" + options: + host: 203.0.113.10 + user: root + remote_root: /srv/minimal + service: minimal + domain: minimal.example.com + listen: 127.0.0.1:18790 + exec_start: "/usr/bin/python3 /srv/minimal/current/server.py" + caddy_block: minimal + +checks: + build: "python3 -m py_compile server.py" + test: "python3 -m unittest discover -s tests -v" + timeout: 120 diff --git a/external_plugins/galadon/examples/minimal/server.py b/external_plugins/galadon/examples/minimal/server.py new file mode 100644 index 00000000..d0550b98 --- /dev/null +++ b/external_plugins/galadon/examples/minimal/server.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import json +import os +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +HERE = Path(__file__).resolve().parent + + +def read_commit() -> str: + commit = os.environ.get("GIT_COMMIT", "").strip() + if commit: + return commit + path = HERE / "commit.txt" + try: + value = path.read_text(encoding="utf-8").strip() + except OSError as exc: + raise SystemExit(f"minimal: cannot read {path}: {exc}") from exc + if not value: + raise SystemExit(f"minimal: {path} is empty") + return value + + +class Handler(BaseHTTPRequestHandler): + commit = "" + + def do_GET(self) -> None: # noqa: N802 + if self.path != "/health.json": + self.send_error(404) + return + body = json.dumps({"ok": True, "commit": self.commit}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args) -> None: + return + + +def main() -> None: + Handler.commit = read_commit() + host = os.environ.get("HOST", "127.0.0.1") + port = int(os.environ.get("PORT", "18790")) + print(f"minimal listening on http://{host}:{port} commit={Handler.commit}") + ThreadingHTTPServer((host, port), Handler).serve_forever() + + +if __name__ == "__main__": + main() diff --git a/external_plugins/galadon/examples/minimal/spec/spec.md b/external_plugins/galadon/examples/minimal/spec/spec.md new file mode 100644 index 00000000..eb44b44a --- /dev/null +++ b/external_plugins/galadon/examples/minimal/spec/spec.md @@ -0,0 +1,14 @@ +# minimal service + +## Problem + +There is no tiny service to exercise a deployment loop against. + +## Acceptance + +- [ ] `GET /health.json` returns the running commit +- [ ] The service starts without third-party dependencies + +## Non-goals + +Authentication, persistence and multi-service routing. diff --git a/external_plugins/galadon/examples/minimal/tests/test_health.py b/external_plugins/galadon/examples/minimal/tests/test_health.py new file mode 100644 index 00000000..f246e16a --- /dev/null +++ b/external_plugins/galadon/examples/minimal/tests/test_health.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import json +import sys +import threading +import unittest +from pathlib import Path +from urllib.request import urlopen + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import server # noqa: E402 + + +class HealthTest(unittest.TestCase): + def test_health_reports_commit(self) -> None: + server.Handler.commit = "abc1234" + httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), server.Handler) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + url = f"http://127.0.0.1:{httpd.server_port}/health.json" + with urlopen(url, timeout=2) as response: + payload = json.loads(response.read()) + self.assertEqual(response.status, 200) + finally: + httpd.shutdown() + httpd.server_close() + self.assertEqual(payload, {"ok": True, "commit": "abc1234"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/external_plugins/galadon/index.js b/external_plugins/galadon/index.js new file mode 100644 index 00000000..26101318 --- /dev/null +++ b/external_plugins/galadon/index.js @@ -0,0 +1,83 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const skillRoots = { + galadon: fileURLToPath(new URL("./skills/galadon/", import.meta.url)), +}; + +function parseFrontmatter(source, skillPath) { + const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(source); + if (!match) throw new Error(`galadon: missing YAML frontmatter in ${skillPath}`); + const header = {}; + for (const rawLine of match[1].split(/\r?\n/)) { + const line = rawLine.trimEnd(); + if (!line || line.startsWith("#") || /^\s/.test(line)) continue; + const colon = line.indexOf(":"); + if (colon < 1) continue; + const key = line.slice(0, colon).trim(); + let value = line.slice(colon + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + header[key] = value; + } + return { header, body: source.slice(match[0].length).trimStart() }; +} + +function loadSkill(skillName) { + const skillRoot = skillRoots[skillName]; + const skillPath = join(skillRoot, "SKILL.md"); + const source = readFileSync(skillPath, "utf8"); + const { header, body } = parseFrontmatter(source, skillPath); + if (typeof header.name !== "string" || typeof header.description !== "string") { + throw new Error(`galadon: invalid skill metadata in ${skillPath}`); + } + return { + name: header.name, + description: header.description, + content: body, + source: "bundled", + path: skillPath, + resourceBase: { kind: "directory", path: skillRoot }, + invocation: { modelInvocable: true, userInvocable: true }, + }; +} + +const skills = Object.keys(skillRoots).map((skillName) => loadSkill(skillName)); + +export const name = "galadon"; +export const inject = ["skills", "agents"]; + +export function apply(ctx) { + for (const skill of skills) ctx.skills.register(skill); + + const agentRegistrations = new Map(); + const registerForAgent = (agent) => { + if (agentRegistrations.has(agent)) return; + const injection = agent.ctx.inject(["skills"], (agentCtx) => { + for (const skill of skills) agentCtx.skills.register(skill); + }); + agentRegistrations.set(agent, () => injection.dispose()); + }; + + for (const agent of ctx.agents.list()) registerForAgent(agent); + + ctx.on("agent/created", ({ agent }) => registerForAgent(agent)); + ctx.on("agent/disposed", ({ agent }) => { + agentRegistrations.get(agent)?.(); + agentRegistrations.delete(agent); + }); + + ctx.effect( + () => () => { + const disposals = [...agentRegistrations.values()].map((dispose) => dispose()); + agentRegistrations.clear(); + return Promise.all(disposals); + }, + "galadon agent registrations", + ); +} diff --git a/external_plugins/galadon/schema/galadon.schema.json b/external_plugins/galadon/schema/galadon.schema.json new file mode 100644 index 00000000..562fbfd7 --- /dev/null +++ b/external_plugins/galadon/schema/galadon.schema.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Galadon project configuration", + "type": "object", + "required": ["project", "template"], + "properties": { + "project": { + "type": "string", + "minLength": 1 + }, + "template": { + "enum": ["web", "api", "static"] + }, + "role": { + "enum": ["solo", "six-ring", "orchestrator"] + }, + "layout": { + "enum": ["none", "webapp", "service", "plugin"], + "default": "none" + }, + "stack": { + "enum": ["python", "node", "none"], + "default": "none" + }, + "paths": { + "type": "object", + "additionalProperties": false, + "properties": { + "spec": { + "type": "string", + "default": "spec/spec.md" + }, + "evidence": { + "type": "string", + "default": "verify/evidence.md" + }, + "retro": { + "type": "string", + "default": "wiki/retro.md" + }, + "release": { + "type": "string", + "default": "release/release.json" + } + } + }, + "deploy": { + "type": "object", + "additionalProperties": true, + "properties": { + "adapter": { + "enum": ["local", "ssh", "k8s"] + }, + "env": { + "type": "string" + }, + "build": { + "type": "string" + }, + "migrate": { + "type": "string" + }, + "release": { + "type": "string" + }, + "probe_url": { + "type": "string" + }, + "probe_expect": { + "type": "string" + }, + "artifact": { + "type": "string" + }, + "options": { + "type": "object", + "additionalProperties": true + } + } + }, + "checks": { + "type": "object", + "properties": { + "build": { + "type": "string" + }, + "test": { + "type": "string" + }, + "timeout": { + "type": "integer", + "minimum": 1 + } + } + } + }, + "additionalProperties": false +} diff --git a/external_plugins/galadon/skills/galadon/SKILL.md b/external_plugins/galadon/skills/galadon/SKILL.md new file mode 100644 index 00000000..717ab476 --- /dev/null +++ b/external_plugins/galadon/skills/galadon/SKILL.md @@ -0,0 +1,70 @@ +--- +name: galadon +description: Initialize and run an agent-ready engineering workspace with the five-stage Galadon loop and its layout/stack scaffold generator. Use when the user says Galadon/工程阵地/初始化工程/项目模板/一键部署/下一步做什么, or wants a new project scaffolded with spec, build, verify, ship and learn gates. +--- + +# Galadon + +Galadon is a five-stage engineering loop: + +```text +Spec -> Build -> Verify -> Ship -> Learn +``` + +The core depends on three ports: `RolePort`, `TemplatePort` and `DeployPort`. +The bundled implementations are references, not the framework. + +## When to use + +- The user wants to initialize a new engineering project. +- The user asks "下一步做什么" or wants an agent to follow a standard loop. +- The user wants a project that can build, verify, ship and keep evidence. +- The user wants to swap deployment style without changing the workflow. + +## Workflow + +1. Run `galadon next` and read `galadon.yaml`. +2. Do only the current phase; write its artifact. +3. Run `galadon check`; repeat until the loop reaches `learn`. + +## Running the CLI + +Resolve it in this order and stop at the first hit: + +1. `galadon` on `PATH`. +2. `../../bin/galadon` relative to this skill directory (the plugin ships the CLI). +3. `PYTHONPATH=/src python3 -m galadon`. + +The CLI only needs Python 3.10+ and `PyYAML`. + +## Commands, layouts and ports + +Do not restate the lists; ask the CLI and read the bundled `README.md` (two +levels up from this skill): + +- `galadon --help` lists every command and flag. +- `galadon init --list-layouts` prints the shipped layouts, stacks and the + exact build/test commands each stack runs. +- `README.md` covers what each layout creates, the layout contract and the + ports: `RolePort` picks who works, `TemplatePort` only chooses which + `docs/profile.md` is written (the code skeleton comes from `--layout` and + `--stack`), and `DeployPort` builds, ships, probes and rolls back. + +Defaults stay `layout=none`, `stack=python`; `--layout none` ignores the stack +and keeps the 0.2.x shape. A non-`none` layout with `--stack python|node` ships +real checks and a passing test, so `galadon check` is green right after `init`; +`--stack none` fails loudly with "no checks configured — declare +checks.build/checks.test". `galadon check` also enforces the layout contract and +names every missing item: report those instead of creating empty stubs. + +## Rules + +- Never skip `Verify` or `Ship`. +- Never report ship success without a version probe match. +- A failed probe rolls back to the previous release and the run exits non-zero. +- Production release requires explicit confirmation in the current session. +- Never write secret values into `galadon.yaml` or git. +- If an adapter is not implemented, say so; do not fake a deployment. +- Keep `AGENTS.md` short. The current phase lives in the managed block. +- Every remote write prints its target first; `teardown` refuses a remote root + that was not created by `bootstrap`. diff --git a/external_plugins/galadon/src/galadon/__init__.py b/external_plugins/galadon/src/galadon/__init__.py new file mode 100644 index 00000000..334b8995 --- /dev/null +++ b/external_plugins/galadon/src/galadon/__init__.py @@ -0,0 +1 @@ +__version__ = "0.3.4" diff --git a/external_plugins/galadon/src/galadon/__main__.py b/external_plugins/galadon/src/galadon/__main__.py new file mode 100644 index 00000000..4009f838 --- /dev/null +++ b/external_plugins/galadon/src/galadon/__main__.py @@ -0,0 +1,11 @@ +import sys + +try: + from .cli import main +except ModuleNotFoundError as exc: + if exc.name != "yaml": + raise + print("galadon: PyYAML is required (pip install pyyaml)", file=sys.stderr) + raise SystemExit(69) + +raise SystemExit(main()) diff --git a/external_plugins/galadon/src/galadon/adapters/__init__.py b/external_plugins/galadon/src/galadon/adapters/__init__.py new file mode 100644 index 00000000..288068cb --- /dev/null +++ b/external_plugins/galadon/src/galadon/adapters/__init__.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from .local import DeployError, LocalCommandAdapter +from .ssh import SshSystemdAdapter + + +def adapter_for(name: str) -> LocalCommandAdapter | SshSystemdAdapter: + if name == "local": + return LocalCommandAdapter() + if name == "ssh": + return SshSystemdAdapter() + raise DeployError( + f"deploy adapter {name!r} is a declared port but has no implementation in this build" + ) + + +__all__ = ["DeployError", "LocalCommandAdapter", "SshSystemdAdapter", "adapter_for"] diff --git a/external_plugins/galadon/src/galadon/adapters/local.py b/external_plugins/galadon/src/galadon/adapters/local.py new file mode 100644 index 00000000..1f3567f6 --- /dev/null +++ b/external_plugins/galadon/src/galadon/adapters/local.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import json +import shutil +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable + +from ..config import render_probe_expect +from ..ports import Artifact, DeployContext, ProbeResult, Release +from ..util import digest_path, git_commit, now_iso, run_shell +from .probe import READY_SECONDS, ProbeNotReady, extract_commit, fetch +from .release import ( + DeployError, + ReleaseLayout, + deploy_lock, + release_name, + release_version, + retain_previous, + switch_current, +) + +Clock = Callable[[], datetime] +CommitProvider = Callable[[Path], str | None] + + +class LocalCommandAdapter: + name = "local" + + def __init__( + self, + clock: Clock | None = None, + commit_provider: CommitProvider | None = None, + sleep: Callable[[float], None] | None = None, + probe_ready: float = READY_SECONDS, + ) -> None: + self._clock = clock or (lambda: datetime.now(timezone.utc)) + self._commit_provider = commit_provider or git_commit + self._sleep = sleep or time.sleep + self._probe_ready = probe_ready + + def describe(self, ctx: DeployContext) -> str: + return f"local workspace {ctx.root}" + + def bootstrap(self, ctx: DeployContext) -> dict[str, Any]: + raise DeployError("local adapter has no bootstrap step; there is nothing to provision") + + def teardown(self, ctx: DeployContext) -> dict[str, Any]: + raise DeployError("local adapter has no teardown step; there is nothing to remove") + + def _run(self, command: str, ctx: DeployContext, stage: str) -> None: + if not command: + return + code, stdout, stderr = run_shell(command, ctx.root, timeout=ctx.config.checks.timeout) + if code != 0: + detail = (stdout + "\n" + stderr).strip()[-2000:] + raise DeployError(f"{stage} failed with exit {code}: {detail}") + + def build(self, ctx: DeployContext) -> Artifact: + self._run(ctx.config.deploy.build, ctx, "build") + configured = ctx.config.deploy.artifact.strip() + if configured: + path = ctx.root / configured + if not path.exists(): + raise DeployError(f"configured artifact does not exist: {configured}") + else: + path = ctx.root / ".galadon" / "artifact.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"project": ctx.config.name, "built_at": now_iso()}, ensure_ascii=False) + + "\n", + encoding="utf-8", + ) + try: + digest = digest_path(path) + except OSError as exc: + raise DeployError(f"cannot hash artifact {path}: {exc}") from exc + return Artifact(path=path, digest=digest) + + def migrate(self, ctx: DeployContext) -> None: + self._run(ctx.config.deploy.migrate, ctx, "migrate") + + def _layout(self, ctx: DeployContext) -> ReleaseLayout: + configured = str(ctx.config.deploy.options.get("root") or "").strip() + if configured: + root = Path(configured).expanduser() + if not root.is_absolute(): + root = ctx.root / root + else: + root = ctx.root / ".galadon" / "deploy" + return ReleaseLayout(root.resolve()) + + def _commit(self, ctx: DeployContext) -> str: + return (ctx.commit or self._commit_provider(ctx.root) or "").strip() + + def release(self, ctx: DeployContext, artifact: Artifact) -> Release: + layout = self._layout(ctx) + artifact_root = artifact.path.resolve() + if layout.root == artifact_root or layout.root.is_relative_to(artifact_root): + raise DeployError( + f"release root {layout.root} is inside artifact {artifact_root}; " + "set deploy.options.root outside the artifact" + ) + commit = self._commit(ctx) + version = commit or artifact.digest[:12] + target = layout.release_dir(release_name(self._clock(), version)) + with deploy_lock(layout): + self._run(ctx.config.deploy.release, ctx, "release") + try: + layout.ensure() + if target.exists() or target.is_symlink(): + raise DeployError(f"release directory already exists: {target}") + target.mkdir(parents=True) + if artifact.path.is_dir(): + shutil.copytree(artifact.path, target, symlinks=True, dirs_exist_ok=True) + else: + shutil.copy2(artifact.path, target / artifact.path.name) + previous = retain_previous(layout) + switch_current(layout, target) + except OSError as exc: + shutil.rmtree(target, ignore_errors=True) + raise DeployError(f"cannot publish release {target}: {exc}") from exc + return Release( + env=ctx.env, + artifact=artifact, + adapter=self.name, + metadata={ + "released_at": now_iso(), + "version": version, + "commit": commit or None, + "release_dir": str(target), + "previous": str(previous or ""), + }, + ) + + def _probe( + self, + ctx: DeployContext, + expected: str, + *, + check_expect: bool, + ) -> ProbeResult: + url = ctx.config.deploy.probe_url.strip() + if not url: + raise DeployError( + "deploy.probe_url is required; Galadon refuses to report success without a probe" + ) + try: + status, body = fetch( + url, ready_seconds=self._probe_ready, sleep=self._sleep + ) + except ProbeNotReady as exc: + return ProbeResult(ok=False, detail=str(exc), url=url) + if status != 200: + return ProbeResult(ok=False, detail=f"HTTP {status}", url=url) + body = body.strip() + try: + payload: Any = json.loads(body) + except json.JSONDecodeError: + payload = None + if isinstance(payload, dict): + actual = extract_commit(payload) + if actual is None: + return ProbeResult( + ok=False, + detail="probe JSON is missing the commit field", + url=url, + ) + if actual != expected: + return ProbeResult( + ok=False, + detail=f"probe commit mismatch: expected {expected}, got {actual or '(missing)'}", + url=url, + actual=actual or None, + ) + return ProbeResult( + ok=True, + detail=f"HTTP {status} commit {actual}", + url=url, + actual=actual, + ) + if not check_expect: + return ProbeResult( + ok=True, + detail=f"HTTP {status} (text body; version not asserted)", + url=url, + actual=body or None, + ) + if body != expected: + return ProbeResult( + ok=False, + detail=( + "probe body does not exactly match deploy.probe_expect: " + f"expected {expected!r}, got {body[:200]!r}" + ), + url=url, + actual=body[:200] or None, + ) + return ProbeResult(ok=True, detail=f"HTTP {status}", url=url, actual=body) + + def probe(self, ctx: DeployContext, release: Release) -> ProbeResult: + expect = render_probe_expect( + ctx.config.deploy.probe_expect.strip(), + str(release.metadata.get("commit") or "") or None, + ) + if not expect: + raise DeployError( + "deploy.probe_expect is required for the local adapter; " + "a bare HTTP 200 is not a version probe" + ) + return self._probe(ctx, expect, check_expect=True) + + def rollback(self, ctx: DeployContext, release: Release) -> None: + layout = self._layout(ctx) + with deploy_lock(layout): + target = layout.previous_release() + version = release_version(target) + switch_current(layout, target, suffix="rollback") + release.metadata["rollback"] = { + "version": version, + "target": str(target), + "ok": False, + } + try: + probe = self._probe(ctx, version, check_expect=False) + except DeployError as exc: + raise DeployError( + f"rolled back to {version} at {target}, but the re-probe failed: {exc}" + ) from exc + release.metadata["rollback"].update( + { + "ok": probe.ok, + "actual": probe.actual, + "probe": { + "ok": probe.ok, + "detail": probe.detail, + "url": probe.url, + }, + } + ) + if not probe.ok: + raise DeployError( + f"rolled back to {version} at {target}, but the re-probe failed: {probe.detail}" + ) diff --git a/external_plugins/galadon/src/galadon/adapters/probe.py b/external_plugins/galadon/src/galadon/adapters/probe.py new file mode 100644 index 00000000..8c49a117 --- /dev/null +++ b/external_plugins/galadon/src/galadon/adapters/probe.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import time +import urllib.error +import urllib.request +from typing import Any, Callable + +READY_SECONDS = 15.0 +RETRY_INTERVAL = 0.25 +_MAX_BODY = 256_000 + + +class ProbeNotReady(RuntimeError): + """Raised when the probe target never answered inside the readiness budget.""" + + def __init__(self, attempts: int, budget: float, last: BaseException) -> None: + self.attempts = attempts + self.budget = budget + self.last = last + super().__init__( + f"no answer within {budget:.0f}s after {attempts} attempt(s): " + f"{type(last).__name__}: {last}" + ) + + +def fetch( + url: str, + *, + ready_seconds: float = READY_SECONDS, + interval: float = RETRY_INTERVAL, + sleep: Callable[[float], None] = time.sleep, +) -> tuple[int, str]: + """Fetch a probe URL, waiting out a just-restarted service. + + `systemctl restart` returns once the process is spawned, not once it is + listening; on a real host the port can refuse connections for ~150ms + afterwards. Connection-level failures are therefore retried within a bounded + readiness budget, while any HTTP answer is final and still subject to the + caller's version check. + """ + deadline = time.monotonic() + ready_seconds + attempts = 0 + while True: + attempts += 1 + try: + with urllib.request.urlopen(url, timeout=15) as response: + return response.getcode(), response.read(_MAX_BODY).decode(errors="replace") + except urllib.error.HTTPError as exc: + return exc.code, "" + except (urllib.error.URLError, TimeoutError, ConnectionError, OSError) as exc: + if time.monotonic() >= deadline: + raise ProbeNotReady(attempts, ready_seconds, exc) from exc + sleep(interval) + + +def extract_commit(payload: dict[str, Any]) -> str | None: + """Return the version field a probe response exposes, if any.""" + if "commit" not in payload: + return None + value = payload["commit"] + return "" if value is None else str(value).strip() diff --git a/external_plugins/galadon/src/galadon/adapters/release.py b/external_plugins/galadon/src/galadon/adapters/release.py new file mode 100644 index 00000000..fb1cc0d0 --- /dev/null +++ b/external_plugins/galadon/src/galadon/adapters/release.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import fcntl +import os +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Iterator + +ROOT_MARKER = ".galadon-root" +RELEASES_DIR = "releases" +SHARED_DIR = "shared" +LOCK_RELATIVE = "shared/deploy.lock" +CURRENT_LINK = "current" +PREVIOUS_LINK = "previous" + + +class DeployError(RuntimeError): + pass + + +@dataclass(frozen=True) +class ReleaseLayout: + """One deployment root shared by the local and ssh adapters.""" + + root: Path + + @property + def releases(self) -> Path: + return self.root / RELEASES_DIR + + @property + def shared(self) -> Path: + return self.root / SHARED_DIR + + @property + def lock(self) -> Path: + return self.root / LOCK_RELATIVE + + @property + def current(self) -> Path: + return self.root / CURRENT_LINK + + @property + def previous(self) -> Path: + return self.root / PREVIOUS_LINK + + def ensure(self) -> None: + self.releases.mkdir(parents=True, exist_ok=True) + self.shared.mkdir(parents=True, exist_ok=True) + + def release_dir(self, name: str) -> Path: + return self.releases / name + + def previous_release(self) -> Path: + if not self.previous.is_symlink(): + raise DeployError(f"galadon: no previous release link at {self.previous}") + try: + target = self.previous.resolve(strict=True) + except OSError as exc: + raise DeployError(f"galadon: previous release is unreadable: {exc}") from exc + if not target.is_dir(): + raise DeployError(f"galadon: previous release is missing: {target}") + if self.releases.resolve() not in target.parents: + raise DeployError( + f"galadon: previous link does not point into {self.releases}: {target}" + ) + return target + + +def release_name(at: datetime, version: str) -> str: + version = str(version).strip() + if not version or "/" in version or version in {".", ".."}: + raise DeployError(f"galadon: invalid release version: {version!r}") + stamp = at.astimezone(timezone.utc).strftime("%Y%m%d-%H%M%S") + return f"{stamp}-{version}" + + +def release_version(target: Path | str) -> str: + name = str(target).rstrip("/").rsplit("/", 1)[-1] + if "-" not in name: + raise DeployError(f"galadon: cannot determine the release version from {str(target)!r}") + return name.rsplit("-", 1)[-1] + + +def _atomic_symlink(link: Path, target: Path, suffix: str) -> None: + temp = link.with_name(f".{link.name}.{suffix}") + if temp.is_symlink() or temp.exists(): + temp.unlink() + temp.symlink_to(target) + os.replace(temp, link) + + +def switch_current(layout: ReleaseLayout, target: Path, *, suffix: str = "new") -> None: + _atomic_symlink(layout.current, target, suffix) + + +def retain_previous(layout: ReleaseLayout) -> Path | None: + if not layout.current.is_symlink(): + return None + old = layout.current.resolve() + if not old.is_dir(): + raise DeployError(f"galadon: current release is missing: {old}") + if layout.releases.resolve() not in old.parents: + raise DeployError(f"galadon: current link does not point into {layout.releases}: {old}") + _atomic_symlink(layout.previous, old, "new") + return old + + +@contextmanager +def deploy_lock(layout: ReleaseLayout) -> Iterator[None]: + try: + layout.shared.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise DeployError( + f"galadon: cannot create deploy lock directory {layout.shared}: {exc}" + ) from exc + try: + handle = layout.lock.open("a+", encoding="utf-8") + except OSError as exc: + raise DeployError(f"galadon: cannot open deploy lock {layout.lock}: {exc}") from exc + with handle: + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as exc: + raise DeployError( + f"galadon: another deployment holds {layout.lock}" + ) from exc + try: + yield + finally: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + +def shell_lock() -> str: + return ( + f'lock="$root/{LOCK_RELATIVE}"\n' + 'exec 9>"$lock"\n' + "if ! flock -n 9; then\n" + ' echo "galadon: another deployment holds $lock" >&2\n' + " exit 75\n" + "fi\n" + ) + + +def shell_retain_previous() -> str: + return ( + 'old=""\n' + 'if [ -L "$root/current" ]; then\n' + ' old=$(readlink -f "$root/current")\n' + "fi\n" + 'if [ -n "$old" ]; then\n' + ' ln -sfn "$old" "$root/.previous.new"\n' + ' mv -Tf "$root/.previous.new" "$root/previous"\n' + "fi\n" + ) + + +def shell_switch_current(target: str, suffix: str) -> str: + return ( + f'ln -sfn {target} "$root/.current.{suffix}"\n' + f'mv -Tf "$root/.current.{suffix}" "$root/current"\n' + ) diff --git a/external_plugins/galadon/src/galadon/adapters/ssh.py b/external_plugins/galadon/src/galadon/adapters/ssh.py new file mode 100644 index 00000000..6d0dba4a --- /dev/null +++ b/external_plugins/galadon/src/galadon/adapters/ssh.py @@ -0,0 +1,777 @@ +from __future__ import annotations + +import json +import re +import shlex +import subprocess +import time +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath +from typing import Any, Callable + +from ..config import render_probe_expect +from ..ports import Artifact, DeployContext, ProbeResult, Release +from ..util import digest_path, git_commit, now_iso +from .probe import READY_SECONDS, ProbeNotReady, extract_commit, fetch +from .release import ( + ROOT_MARKER, + DeployError, + release_name, + release_version, + shell_lock, + shell_retain_previous, + shell_switch_current, +) + +Runner = Callable[..., tuple[int, str, str]] +Clock = Callable[[], datetime] +CommitProvider = Callable[[Path], str | None] + +_SSH_OPTIONS = ( + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=15", + "-o", + "ServerAliveInterval=15", + "-o", + "ServerAliveCountMax=3", +) +_SAFE_HOST = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:\[\]-]*$") +_SAFE_USER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$") +_SAFE_SERVICE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.@-]*$") +_SAFE_DOMAIN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.-]*$") +_SAFE_LISTEN = re.compile(r"^[A-Za-z0-9_.:\[\]-]+$") +_SAFE_MARKER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$") +_SAFE_PATH = re.compile(r"^[A-Za-z0-9._/-]+$") + + +def subprocess_runner( + argv: list[str], + *, + cwd: Path | None = None, + input: str | None = None, + timeout: int = 300, +) -> tuple[int, str, str]: + try: + proc = subprocess.run( + argv, + cwd=None if cwd is None else str(cwd), + input=input, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except subprocess.TimeoutExpired as exc: + stdout = exc.stdout or "" + stderr = exc.stderr or "" + if isinstance(stdout, bytes): + stdout = stdout.decode(errors="replace") + if isinstance(stderr, bytes): + stderr = stderr.decode(errors="replace") + return 124, stdout, stderr + f"\ncommand timed out after {timeout}s" + except OSError as exc: + return 127, "", f"{type(exc).__name__}: {exc}" + return proc.returncode, proc.stdout, proc.stderr + + +def _q(value: Any) -> str: + return shlex.quote(str(value)) + + +def _parse_fields(stdout: str) -> dict[str, str]: + fields: dict[str, str] = {} + for line in stdout.splitlines(): + if line.startswith("GALADON_") and "=" in line: + key, value = line.split("=", 1) + fields[key] = value + return fields + + +class SshSystemdAdapter: + name = "ssh" + + def __init__( + self, + runner: Runner | None = None, + clock: Clock | None = None, + commit_provider: CommitProvider | None = None, + sleep: Callable[[float], None] | None = None, + probe_ready: float = READY_SECONDS, + ) -> None: + self._runner = runner or subprocess_runner + self._clock = clock or (lambda: datetime.now(timezone.utc)) + self._commit_provider = commit_provider or git_commit + self._sleep = sleep or time.sleep + self._probe_ready = probe_ready + + def _option(self, ctx: DeployContext, key: str) -> str: + value = ctx.config.deploy.options.get(key) + return "" if value is None else str(value).strip() + + def _target(self, ctx: DeployContext) -> str: + host = self._option(ctx, "host") + user = self._option(ctx, "user") + if not _SAFE_HOST.fullmatch(host) or not _SAFE_USER.fullmatch(user): + raise DeployError("deploy.options.host or user is missing or invalid") + return f"{user}@{host}" + + def _root(self, ctx: DeployContext) -> str: + root = self._option(ctx, "remote_root").rstrip("/") + if not root.startswith("/"): + raise DeployError("deploy.options.remote_root must be an absolute path") + if not _SAFE_PATH.fullmatch(root): + raise DeployError( + "deploy.options.remote_root may only contain letters, digits, " + "dot, underscore, hyphen and slash" + ) + parts = PurePosixPath(root).parts + if len(parts) < 3 or ".." in parts or "." in parts: + raise DeployError( + "deploy.options.remote_root must be a specific directory below /" + ) + return root + + def _service(self, ctx: DeployContext) -> str: + service = self._option(ctx, "service") + if not _SAFE_SERVICE.fullmatch(service): + raise DeployError("deploy.options.service is missing or invalid") + return service + + def _run( + self, + argv: list[str], + *, + ctx: DeployContext, + stage: str, + input: str | None = None, + timeout: int | None = None, + ) -> tuple[str, str]: + code, stdout, stderr = self._runner( + argv, + cwd=ctx.root, + input=input, + timeout=timeout or ctx.config.checks.timeout, + ) + if code != 0: + detail = (stdout + "\n" + stderr).strip()[-4000:] + raise DeployError(f"{stage} failed with exit {code}: {detail}") + return stdout, stderr + + def _remote( + self, + ctx: DeployContext, + script: str, + stage: str, + timeout: int | None = None, + ) -> dict[str, str]: + stdout, _ = self._run( + ["ssh", *_SSH_OPTIONS, self._target(ctx), "bash -s"], + ctx=ctx, + stage=stage, + input=script, + timeout=timeout, + ) + return _parse_fields(stdout) + + def _local_command(self, ctx: DeployContext, command: str, stage: str) -> None: + if not command: + return + self._run( + ["/bin/sh", "-c", command], + ctx=ctx, + stage=stage, + ) + + def _artifact_path(self, ctx: DeployContext) -> Path: + configured = ctx.config.deploy.artifact.strip() + if not configured: + raise DeployError("deploy.artifact is required for the ssh adapter") + root = ctx.root.resolve() + path = (root / configured).resolve() + if not path.is_relative_to(root): + raise DeployError(f"deploy.artifact escapes the project root: {configured}") + return path + + def preflight(self, ctx: DeployContext) -> dict[str, Any]: + artifact = self._artifact_path(ctx) + if not ctx.config.deploy.build.strip() and not artifact.exists(): + raise DeployError( + f"artifact does not exist and no build command is configured: {artifact}" + ) + root = self._root(ctx) + service = self._service(ctx) + drift_check = "" + try: + unit = self._unit_text(ctx) + except DeployError: + unit = "" + if unit: + unit_path = f"/etc/systemd/system/{service}.service" + drift_check = ( + "expected_unit=$(mktemp)\n" + "cat > \"$expected_unit\" <<'GALADON_UNIT'\n" + f"{unit}" + "GALADON_UNIT\n" + f'if ! cmp -s "$expected_unit" {_q(unit_path)}; then\n' + ' rm -f "$expected_unit"\n' + f' echo "galadon: systemd unit drift detected: {unit_path}" >&2\n' + ' echo "galadon: run `galadon bootstrap --env ` to converge the unit" >&2\n' + " exit 78\n" + "fi\n" + 'rm -f "$expected_unit"\n' + ) + script = f"""set -euo pipefail +export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +root={_q(root)} +service={_q(service)} +test -d "$root" || {{ echo "galadon: remote_root is missing: $root" >&2; exit 66; }} +test -w "$root" || {{ echo "galadon: remote_root is not writable: $root" >&2; exit 66; }} +command -v systemctl >/dev/null 2>&1 || {{ echo "galadon: systemctl is unavailable" >&2; exit 69; }} +command -v flock >/dev/null 2>&1 || {{ echo "galadon: flock is unavailable" >&2; exit 69; }} +systemctl cat "$service" >/dev/null 2>&1 || {{ echo "galadon: systemd service is not installed: $service" >&2; exit 66; }} +{drift_check}printf 'GALADON_OK=1\\n' +""" + self._remote(ctx, script, "preflight") + return {"target": self._target(ctx), "remote_root": root, "service": service} + + def build(self, ctx: DeployContext) -> Artifact: + self._local_command(ctx, ctx.config.deploy.build, "build") + path = self._artifact_path(ctx) + if not path.exists(): + raise DeployError(f"configured artifact does not exist: {path}") + try: + digest = digest_path(path) + except OSError as exc: + raise DeployError(f"cannot hash artifact {path}: {exc}") from exc + return Artifact(path=path, digest=digest) + + def migrate(self, ctx: DeployContext) -> None: + self._local_command(ctx, ctx.config.deploy.migrate, "migrate") + + def release(self, ctx: DeployContext, artifact: Artifact) -> Release: + commit = self._commit_provider(ctx.root) + if not commit: + raise DeployError("cannot determine the git commit for this build") + + self._local_command(ctx, ctx.config.deploy.release, "release hook") + root = self._root(ctx) + service = self._service(ctx) + run_user = self._run_user(ctx) + release_dir = f"{root}/releases/{release_name(self._clock(), commit)}" + + prepare = f"""set -euo pipefail +root={_q(root)} +test -d "$root" || {{ echo "galadon: remote_root is missing: $root" >&2; exit 66; }} +install -d -m 0755 {_q(root + "/releases")} {_q(root + "/shared")} +test ! -e {_q(release_dir)} || {{ echo "galadon: release directory already exists: {release_dir}" >&2; exit 73; }} +printf 'GALADON_OK=1\\n' +""" + self._remote(ctx, prepare, "release prepare") + + source = str(artifact.path) + ("/" if artifact.path.is_dir() else "") + destination = f"{self._target(ctx)}:{release_dir}/" + self._run( + [ + "rsync", + "-az", + "-e", + " ".join(["ssh", *_SSH_OPTIONS]), + source, + destination, + ], + ctx=ctx, + stage="release rsync", + ) + + finalize = f"""set -euo pipefail +export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +root={_q(root)} +service={_q(service)} +run_user={_q(run_user)} +release={_q(release_dir)} +{shell_lock()}test -d "$release" || {{ echo "galadon: release directory is missing: $release" >&2; exit 66; }} +chown -R "$run_user:$run_user" "$release" +{shell_retain_previous()}{shell_switch_current('"$release"', "new")}if ! systemctl restart "$service"; then + echo "galadon: service restart failed; restoring previous current" >&2 + if [ -n "$old" ]; then + ln -sfn "$old" "$root/.current.restore" + mv -Tf "$root/.current.restore" "$root/current" + if ! systemctl restart "$service"; then + echo "galadon: previous release also failed to restart" >&2 + fi + else + rm -f "$root/current" + fi + exit 1 +fi +printf 'GALADON_PREVIOUS=%s\\n' "$old" +printf 'GALADON_CURRENT=%s\\n' "$release" +printf 'GALADON_OK=1\\n' +""" + fields = self._remote(ctx, finalize, "release finalize") + return Release( + env=ctx.env, + artifact=artifact, + adapter=self.name, + metadata={ + "released_at": now_iso(), + "commit": commit, + "release_dir": release_dir, + "previous": fields.get("GALADON_PREVIOUS", ""), + }, + ) + + def _probe( + self, + ctx: DeployContext, + expected_commit: str, + *, + check_expect: bool = True, + ) -> ProbeResult: + url = ctx.config.deploy.probe_url.strip() + if not url: + raise DeployError( + "deploy.probe_url is required; Galadon refuses to report success without a probe" + ) + try: + status, body = fetch( + url, ready_seconds=self._probe_ready, sleep=self._sleep + ) + except ProbeNotReady as exc: + raise DeployError(f"probe request failed: {exc}") from exc.last + if status != 200: + raise DeployError(f"probe returned HTTP {status}") + try: + payload = json.loads(body) + except json.JSONDecodeError as exc: + raise DeployError(f"probe response is not JSON: {exc}") from exc + if not isinstance(payload, dict): + raise DeployError("probe response JSON must be an object") + actual = extract_commit(payload) + if actual != expected_commit: + raise DeployError( + f"probe commit mismatch: expected {expected_commit}, got {actual or '(missing)'}" + ) + # A rollback re-check only proves the old version is serving again; probe_expect may + # describe text that exists exclusively in the failed build. + if check_expect: + expect = render_probe_expect(ctx.config.deploy.probe_expect.strip(), expected_commit) + if expect and expect not in body: + raise DeployError(f"probe body missing expected text: {expect}") + return ProbeResult( + ok=True, + detail=f"HTTP {status} commit {actual}", + url=url, + actual=actual, + ) + + def probe(self, ctx: DeployContext, release: Release) -> ProbeResult: + expected = str(release.metadata.get("commit", "")) + if not expected: + raise DeployError("release metadata is missing the build commit") + return self._probe(ctx, expected) + + def rollback(self, ctx: DeployContext, release: Release) -> None: + root = self._root(ctx) + service = self._service(ctx) + script = f"""set -euo pipefail +export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +root={_q(root)} +service={_q(service)} +{shell_lock()}test -L "$root/previous" || {{ echo "galadon: no previous release link" >&2; exit 66; }} +target=$(readlink -f "$root/previous") +test -d "$target" || {{ echo "galadon: previous release is missing: $target" >&2; exit 66; }} +case "$target" in + "$root/releases/"*) ;; + *) echo "galadon: previous link does not point into $root/releases: $target" >&2; exit 65 ;; +esac +{shell_switch_current('"$target"', "rollback")}systemctl restart "$service" +printf 'GALADON_ROLLBACK_TO=%s\\n' "$target" +printf 'GALADON_OK=1\\n' +""" + fields = self._remote(ctx, script, "rollback") + target = ( + fields.get("GALADON_ROLLBACK_TO") or str(release.metadata.get("previous", "")) + ).rstrip("/") + version = release_version(target) + release.metadata["rollback"] = { + "version": version, + "target": target, + "ok": False, + } + probe = self._probe(ctx, version, check_expect=False) + release.metadata["rollback"].update( + { + "ok": True, + "actual": probe.actual, + "probe": { + "ok": probe.ok, + "detail": probe.detail, + "url": probe.url, + }, + } + ) + + def _run_user(self, ctx: DeployContext) -> str: + user = self._option(ctx, "run_user") or "galadon" + if not _SAFE_USER.fullmatch(user): + raise DeployError("deploy.options.run_user is invalid") + return user + + def _manage_user(self, ctx: DeployContext) -> bool: + raw = ctx.config.deploy.options.get("manage_user", True) + if isinstance(raw, bool): + return raw + return str(raw).strip().lower() not in {"false", "no", "0", "off"} + + def describe(self, ctx: DeployContext) -> str: + return f"{self._target(ctx)}:{self._root(ctx)}" + + def _bootstrap_values(self, ctx: DeployContext) -> tuple[str, str, str]: + values = { + "domain": self._option(ctx, "domain"), + "listen": self._option(ctx, "listen"), + "exec_start": self._option(ctx, "exec_start"), + "caddy_block": self._option(ctx, "caddy_block"), + } + missing = [key for key, value in values.items() if not value] + if missing: + raise DeployError( + "bootstrap requires deploy.options domain, listen, exec_start, caddy_block; " + f"missing: {', '.join(missing)}" + ) + if not _SAFE_DOMAIN.fullmatch(values["domain"]): + raise DeployError("deploy.options.domain is invalid") + if not _SAFE_LISTEN.fullmatch(values["listen"]): + raise DeployError("deploy.options.listen is invalid") + if not _SAFE_MARKER.fullmatch(values["caddy_block"]): + raise DeployError("deploy.options.caddy_block is invalid") + if "\n" in values["exec_start"] or "\r" in values["exec_start"]: + raise DeployError("deploy.options.exec_start must be a single line") + return values["domain"], values["listen"], values["exec_start"] + + def _unit_text(self, ctx: DeployContext) -> str: + root = self._root(ctx) + service = self._service(ctx) + run_user = self._run_user(ctx) + exec_start = self._bootstrap_values(ctx)[2] + return f"""[Unit] +Description=Galadon service {service} +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User={run_user} +Group={run_user} +WorkingDirectory={root}/current +ExecStart={exec_start} +Restart=on-failure +RestartSec=3 +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target +""" + + def bootstrap(self, ctx: DeployContext) -> dict[str, Any]: + root = self._root(ctx) + service = self._service(ctx) + run_user = self._run_user(ctx) + domain, listen, exec_start = self._bootstrap_values(ctx) + marker = self._option(ctx, "caddy_block") + unit_path = f"/etc/systemd/system/{service}.service" + unit = self._unit_text(ctx) + block = ( + f"# galadon:begin {marker}\n" + f"{domain} {{\n" + " import access_log\n" + ' header X-Robots-Tag "noindex, nofollow"\n' + " respond /robots.txt `User-agent: *\n" + "Disallow: /` 200\n" + f" reverse_proxy {listen}\n" + "}\n" + f"# galadon:end {marker}\n" + ) + script = f"""set -euo pipefail +export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +root={_q(root)} +service={_q(service)} +run_user={_q(run_user)} +domain={_q(domain)} +listen={_q(listen)} +marker={_q(marker)} +unit_path={_q(unit_path)} + +marker_file="$root/{ROOT_MARKER}" +if [ -d "$root" ]; then + if [ -f "$marker_file" ]; then + existing=$(cat "$marker_file") + if [ "$existing" != "$marker" ]; then + echo "galadon: refusing to bootstrap $root: it belongs to marker $existing" >&2 + exit 65 + fi + elif [ -n "$(ls -A "$root" 2>/dev/null)" ]; then + echo "galadon: refusing to bootstrap $root: directory is not empty and carries no {ROOT_MARKER}" >&2 + exit 65 + fi +fi + +manage_user={1 if self._manage_user(ctx) else 0} +user_created=0 +if [ "$manage_user" -eq 1 ]; then + if ! getent group "$run_user" >/dev/null 2>&1; then + groupadd --system "$run_user" + fi + if id -u "$run_user" >/dev/null 2>&1; then + user_created=0 + else + useradd --system --gid "$run_user" --no-create-home --shell /usr/sbin/nologin "$run_user" + user_created=1 + fi +else + id -u "$run_user" >/dev/null 2>&1 || {{ + echo "galadon: manage_user is false but $run_user does not exist" >&2 + exit 66 + }} +fi +install -d -m 0755 "$root" +install -d -m 0755 -o "$run_user" -g "$run_user" "$root/releases" "$root/shared" +printf '%s\\n' "$marker" > "$root/{ROOT_MARKER}" +chmod 0644 "$root/{ROOT_MARKER}" + +unit_tmp=$(mktemp) +cat > "$unit_tmp" <<'GALADON_UNIT' +{unit}GALADON_UNIT +if [ -f "$unit_path" ] && cmp -s "$unit_tmp" "$unit_path"; then + unit_changed=0 +else + install -m 0644 "$unit_tmp" "$unit_path" + unit_changed=1 +fi +rm -f "$unit_tmp" +if [ "$unit_changed" -eq 1 ]; then + systemctl daemon-reload +fi +systemctl enable "$service" >/dev/null +if [ -L "$root/current" ]; then + if [ "$unit_changed" -eq 1 ]; then + systemctl restart "$service" + else + systemctl start "$service" + fi +fi + +caddyfile=/etc/caddy/Caddyfile +block_file=$(mktemp) +cat > "$block_file" <<'GALADON_CADDY_BLOCK' +{block}GALADON_CADDY_BLOCK +begin="# galadon:begin $marker" +end="# galadon:end $marker" +new_file=$(mktemp) +has_begin=0 +has_end=0 +if [ -f "$caddyfile" ]; then + if grep -Fqx "$begin" "$caddyfile"; then has_begin=1; fi + if grep -Fqx "$end" "$caddyfile"; then has_end=1; fi +fi +if [ "$has_begin" -ne "$has_end" ]; then + echo "galadon: incomplete Caddy marker block for $marker" >&2 + exit 65 +fi +if [ -f "$caddyfile" ] && [ "$has_begin" -eq 1 ]; then + awk -v begin="$begin" -v end="$end" -v blockfile="$block_file" ' + BEGIN {{ while ((getline line < blockfile) > 0) block = block line "\\n" }} + $0 == begin {{ printf "%s", block; skip=1; next }} + skip == 1 && $0 == end {{ skip=0; next }} + skip == 1 {{ next }} + {{ print }} + ' "$caddyfile" > "$new_file" +elif [ -f "$caddyfile" ]; then + cp "$caddyfile" "$new_file" + printf '\\n' >> "$new_file" + cat "$block_file" >> "$new_file" +else + : > "$new_file" + cat "$block_file" >> "$new_file" +fi + +backup="" +caddy_changed=0 +if [ -f "$caddyfile" ] && cmp -s "$new_file" "$caddyfile"; then + caddy_changed=0 +else + if [ -f "$caddyfile" ]; then + backup="$caddyfile.galadon-bak.$(date -u +%Y%m%dT%H%M%SZ).$$" + cp -p "$caddyfile" "$backup" + fi + if [ -f "$caddyfile" ]; then + cat "$new_file" > "$caddyfile" + else + install -m 0644 "$new_file" "$caddyfile" + fi + if ! caddy validate --config "$caddyfile" --adapter caddyfile; then + if [ -n "$backup" ]; then + cp -p "$backup" "$caddyfile" + else + rm -f "$caddyfile" + fi + rm -f "$block_file" "$new_file" + echo "galadon: caddy validate failed; previous config restored and caddy was not reloaded" >&2 + exit 1 + fi + if ! systemctl reload caddy; then + if [ -n "$backup" ]; then + cp -p "$backup" "$caddyfile" + if ! systemctl reload caddy; then + echo "galadon: previous Caddyfile restored but caddy reload still failed" >&2 + fi + else + rm -f "$caddyfile" + fi + rm -f "$block_file" "$new_file" + exit 1 + fi + caddy_changed=1 +fi +rm -f "$block_file" "$new_file" + +printf 'GALADON_USER_CREATED=%s\\n' "$user_created" +printf 'GALADON_UNIT_CHANGED=%s\\n' "$unit_changed" +printf 'GALADON_CADDY_CHANGED=%s\\n' "$caddy_changed" +printf 'GALADON_CADDY_BACKUP=%s\\n' "$backup" +printf 'GALADON_OK=1\\n' +""" + fields = self._remote(ctx, script, "bootstrap") + return { + "target": self._target(ctx), + "remote_root": root, + "service": service, + "user": run_user, + "user_created": fields.get("GALADON_USER_CREATED") == "1", + "unit": unit_path, + "unit_changed": fields.get("GALADON_UNIT_CHANGED") == "1", + "caddy": { + "marker": marker, + "changed": fields.get("GALADON_CADDY_CHANGED") == "1", + "backup": fields.get("GALADON_CADDY_BACKUP") or None, + }, + } + + def teardown(self, ctx: DeployContext) -> dict[str, Any]: + root = self._root(ctx) + service = self._service(ctx) + run_user = self._run_user(ctx) + manage_user = self._manage_user(ctx) + marker = self._option(ctx, "caddy_block") + if not marker or not _SAFE_MARKER.fullmatch(marker): + raise DeployError("teardown requires a valid deploy.options.caddy_block") + unit_path = f"/etc/systemd/system/{service}.service" + script = f"""set -euo pipefail +export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +root={_q(root)} +service={_q(service)} +marker={_q(marker)} +unit_path={_q(unit_path)} +run_user={_q(run_user)} +manage_user={1 if manage_user else 0} + +marker_file="$root/{ROOT_MARKER}" +if [ ! -f "$marker_file" ]; then + echo "galadon: refusing to remove $root: $marker_file is missing" >&2 + exit 65 +fi +if [ "$(cat "$marker_file")" != "$marker" ]; then + echo "galadon: refusing to remove $root: marker file does not match $marker" >&2 + exit 65 +fi + +service_removed=0 +if [ -f "$unit_path" ]; then + systemctl disable --now "$service" + rm -f "$unit_path" + systemctl daemon-reload + service_removed=1 +fi + +caddyfile=/etc/caddy/Caddyfile +begin="# galadon:begin $marker" +end="# galadon:end $marker" +backup="" +caddy_changed=0 +has_begin=0 +has_end=0 +if [ -f "$caddyfile" ]; then + if grep -Fqx "$begin" "$caddyfile"; then has_begin=1; fi + if grep -Fqx "$end" "$caddyfile"; then has_end=1; fi +fi +if [ "$has_begin" -ne "$has_end" ]; then + echo "galadon: incomplete Caddy marker block for $marker" >&2 + exit 65 +fi +if [ "$has_begin" -eq 1 ]; then + new_file=$(mktemp) + awk -v begin="$begin" -v end="$end" ' + $0 == begin {{ skip=1; next }} + skip == 1 && $0 == end {{ skip=0; next }} + skip == 1 {{ next }} + {{ print }} + ' "$caddyfile" > "$new_file" + backup="$caddyfile.galadon-bak.$(date -u +%Y%m%dT%H%M%SZ).$$" + cp -p "$caddyfile" "$backup" + cat "$new_file" > "$caddyfile" + rm -f "$new_file" + if ! caddy validate --config "$caddyfile" --adapter caddyfile; then + cp -p "$backup" "$caddyfile" + echo "galadon: caddy validate failed; previous config restored and caddy was not reloaded" >&2 + exit 1 + fi + if ! systemctl reload caddy; then + cp -p "$backup" "$caddyfile" + if ! systemctl reload caddy; then + echo "galadon: previous Caddyfile restored but caddy reload still failed" >&2 + fi + exit 1 + fi + caddy_changed=1 +fi + +rm -rf "$root" +user_removed=0 +user_skipped="" +if [ "$manage_user" -eq 1 ] && id -u "$run_user" >/dev/null 2>&1; then + if userdel "$run_user" 2>/dev/null; then + user_removed=1 + elif pgrep -u "$run_user" >/dev/null 2>&1; then + user_skipped="still in use by another service" + echo "galadon: left $run_user in place: still in use by another service" >&2 + else + echo "galadon: could not remove system user $run_user" >&2 + exit 1 + fi +fi +printf 'GALADON_SERVICE_REMOVED=%s\\n' "$service_removed" +printf 'GALADON_CADDY_CHANGED=%s\\n' "$caddy_changed" +printf 'GALADON_CADDY_BACKUP=%s\\n' "$backup" +printf 'GALADON_ROOT_REMOVED=1\\n' +printf 'GALADON_USER_REMOVED=%s\\n' "$user_removed" +printf 'GALADON_USER_SKIPPED=%s\\n' "$user_skipped" +printf 'GALADON_OK=1\\n' +""" + fields = self._remote(ctx, script, "teardown") + return { + "target": self._target(ctx), + "remote_root": root, + "service": service, + "service_removed": fields.get("GALADON_SERVICE_REMOVED") == "1", + "caddy": { + "marker": marker, + "changed": fields.get("GALADON_CADDY_CHANGED") == "1", + "backup": fields.get("GALADON_CADDY_BACKUP") or None, + }, + "root_removed": fields.get("GALADON_ROOT_REMOVED") == "1", + "user_removed": fields.get("GALADON_USER_REMOVED") == "1", + "user_skip_reason": fields.get("GALADON_USER_SKIPPED") or None, + } diff --git a/external_plugins/galadon/src/galadon/cli.py b/external_plugins/galadon/src/galadon/cli.py new file mode 100644 index 00000000..206264a2 --- /dev/null +++ b/external_plugins/galadon/src/galadon/cli.py @@ -0,0 +1,402 @@ +from __future__ import annotations + +import argparse +import json +import shutil +import sys +from pathlib import Path + +from . import __version__ +from .adapters import DeployError, adapter_for +from .config import LAYOUTS, STACKS, ConfigError, load_config, render_probe_expect +from .gates import check_learn, run_checks +from .layouts import layout_listing +from .loop import ( + CHECKS_PATH, + dump_payload, + infer_phase, + next_payload, + save_state, + uncommitted_paths, + update_agents_block, +) +from .ports import DeployContext, ProbeResult +from .templates import FileTemplateProvider, TemplateError +from .util import git_commit, now_iso, write_json + + +def _root(value: str) -> Path: + return Path(value).expanduser().resolve() + + +def _sync_agents(root: Path, config) -> dict: + payload = next_payload(root, config) + update_agents_block(root, payload) + save_state(root, payload) + return payload + + +def cmd_init(args: argparse.Namespace) -> int: + if args.list_layouts: + print(layout_listing()) + return 0 + root = _root(args.path) + if root.exists() and any(root.iterdir()) and not args.force: + raise ConfigError(f"{root} is not empty; use --force to add Galadon files") + root.mkdir(parents=True, exist_ok=True) + name = args.name or root.name + stack = args.stack if args.layout != "none" else "none" + provider = FileTemplateProvider() + result = provider.render( + root, + args.template, + args.role, + name, + force=args.force, + layout=args.layout, + stack=stack, + ) + for directory in ("spec", "wiki", "verify", "release", ".galadon"): + (root / directory).mkdir(parents=True, exist_ok=True) + config = load_config(root) + payload = _sync_agents(root, config) + if args.json: + print( + json.dumps( + { + "root": str(root), + "layout": args.layout, + "stack": stack, + **result, + **payload, + }, + ensure_ascii=False, + indent=2, + ) + ) + else: + print(f"Galadon initialized: {root}") + print(f" template: {args.template}") + print(f" layout: {args.layout} / {stack}") + print(f" role: {args.role}") + print(f" phase: {payload['phase']}") + print(f" next: {payload['next']}") + return 0 + + +def cmd_next(args: argparse.Namespace) -> int: + root = _root(args.path) + config = load_config(root) + payload = _sync_agents(root, config) + if args.json: + print(dump_payload(payload)) + else: + print(f"{config.name}: {payload['phase']} / {payload['role']}") + print(payload["next"]) + return 0 + + +def cmd_check(args: argparse.Namespace) -> int: + root = _root(args.path) + config = load_config(root) + phase = infer_phase(root, config) + report = check_learn(root, config) if phase == "learn" else run_checks(root, config) + payload = _sync_agents(root, config) + if args.json: + print(json.dumps({**report, "next": payload}, ensure_ascii=False, indent=2)) + else: + print(f"Galadon check: {'PASS' if report['ok'] else 'FAIL'}") + for item in report["results"]: + print(f" [{item['code']}] {item['id']}: {item.get('stdout', '').strip()}") + print(f" phase: {payload['phase']}") + print(f" next: {payload['next']}") + return 0 if report["ok"] else 1 + + +def cmd_new(args: argparse.Namespace) -> int: + root = _root(args.path) + config = load_config(root) + phase = infer_phase(root, config) + if phase != "done": + raise ConfigError( + f"the current loop is still in {phase}; finish it before starting a new one" + ) + archive = root / ".galadon" / "history" / now_iso().replace(":", "-") + archived: list[str] = [] + for relative in (CHECKS_PATH, config.paths.release): + source = root / relative + if source.is_file(): + archive.mkdir(parents=True, exist_ok=True) + destination = archive / source.name + shutil.move(str(source), str(destination)) + archived.append(str(destination.relative_to(root))) + payload = _sync_agents(root, config) + if args.json: + print(json.dumps({"archived": archived, "next": payload}, ensure_ascii=False, indent=2)) + else: + print(f"Galadon new loop: {config.name}") + for item in archived: + print(f" archived: {item}") + print(f" phase: {payload['phase']}") + print(f" next: {payload['next']}") + return 0 + + +def cmd_ship(args: argparse.Namespace) -> int: + root = _root(args.path) + config = load_config(root) + env = args.env or config.deploy.env + phase = infer_phase(root, config) + if phase in {"learn", "done"}: + raise ConfigError("this change has already shipped; write the retro or start a new spec") + + uncommitted = uncommitted_paths(root, config) + if uncommitted: + raise ConfigError( + "uncommitted changes would ship under commit " + f"{git_commit(root) or '(unknown)'}: {', '.join(uncommitted)}; commit them first" + ) + + report = run_checks(root, config) + if not report["ok"]: + payload = _sync_agents(root, config) + print("Galadon ship refused: verification failed", file=sys.stderr) + print(payload["next"], file=sys.stderr) + return 2 + + adapter = adapter_for(config.deploy.adapter) + ctx = DeployContext(root=root, config=config, env=env) + + if args.dry_run: + plan = { + "project": config.name, + "env": env, + "adapter": adapter.name, + "target": adapter.describe(ctx), + "build": config.deploy.build or "(no command)", + "migrate": config.deploy.migrate or "(no command)", + "release": config.deploy.release or "(no command)", + "probe": config.deploy.probe_url or "(not configured: ship will fail)", + } + if env == "prod": + plan["requires"] = "--yes-prod" + print(json.dumps(plan, ensure_ascii=False, indent=2) if args.json else "\n".join(f"{k}: {v}" for k, v in plan.items())) + return 0 + + if env == "prod" and not args.yes_prod: + raise ConfigError("production release requires --yes-prod in the current session") + + print(f"Galadon ship: {env} -> {adapter.describe(ctx)}", file=sys.stderr) + preflight = getattr(adapter, "preflight", None) + if preflight is not None: + preflight(ctx) + + artifact = adapter.build(ctx) + commit = git_commit(root) + ctx.commit = commit + if "{{COMMIT}}" in config.deploy.probe_expect: + config.deploy.probe_expect = render_probe_expect(config.deploy.probe_expect, commit) + + adapter.migrate(ctx) + release = adapter.release(ctx, artifact) + build_commit = str(release.metadata.get("commit") or commit or "") + + try: + probe = adapter.probe(ctx, release) + except DeployError as exc: + probe = ProbeResult(ok=False, detail=str(exc), url=config.deploy.probe_url or None) + + rollback = None + if not probe.ok: + rollback = { + "attempted": True, + "ok": False, + "version": None, + "error": None, + "probe": None, + } + try: + adapter.rollback(ctx, release) + info = release.metadata.get("rollback", {}) + rollback.update(info) + rollback["attempted"] = True + rollback["ok"] = bool(info.get("ok", True)) + except DeployError as exc: + info = release.metadata.get("rollback", {}) + rollback.update(info) + rollback["attempted"] = True + rollback["ok"] = False + rollback["error"] = str(exc) + if rollback.get("ok"): + live = rollback.get("actual") or rollback.get("version") or "(unknown)" + target = f" at {rollback['target']}" if rollback.get("target") else "" + print( + f"galadon: probe failed; rolled back automatically: " + f"release {live} is live again{target}", + file=sys.stderr, + ) + elif rollback.get("error"): + print( + f"galadon: probe failed; rollback failed: {rollback['error']}", + file=sys.stderr, + ) + + actual_commit = probe.actual + if rollback is not None and rollback.get("ok"): + actual_commit = str(rollback.get("actual") or rollback.get("version") or "") or probe.actual + record = { + "ok": probe.ok, + "at": now_iso(), + "env": env, + "adapter": adapter.name, + "commit": build_commit or None, + "actual_commit": actual_commit, + "artifact": { + "path": str(artifact.path.relative_to(root)) if artifact.path.is_relative_to(root) else str(artifact.path), + "digest": artifact.digest, + }, + "probe": { + "ok": probe.ok, + "detail": probe.detail, + "url": probe.url, + "actual": probe.actual, + }, + } + if rollback is not None: + record["rollback"] = rollback + write_json(root / config.paths.release, record) + payload = _sync_agents(root, config) + if args.json: + print(json.dumps({"release": record, "next": payload}, ensure_ascii=False, indent=2)) + else: + print(f"Galadon ship: {'PASS' if probe.ok else 'FAIL'} ({env})") + print(f" probe: {probe.detail}") + if rollback is not None: + if rollback.get("ok"): + live = rollback.get("actual") or rollback.get("version") + print(f" rollback: release {live} is live again") + elif rollback.get("error"): + print(f" rollback: failed: {rollback['error']}") + print(f" next: {payload['next']}") + return 0 if probe.ok else 1 + + +def cmd_bootstrap(args: argparse.Namespace) -> int: + root = _root(args.path) + config = load_config(root) + env = args.env or config.deploy.env + if env == "prod" and not args.yes_prod: + raise ConfigError("production bootstrap requires --yes-prod in the current session") + adapter = adapter_for(config.deploy.adapter) + ctx = DeployContext(root=root, config=config, env=env) + print(f"Galadon bootstrap: {env} -> {adapter.describe(ctx)}", file=sys.stderr) + result = adapter.bootstrap(ctx) + if args.json: + print(json.dumps(result, ensure_ascii=False, indent=2)) + else: + print(f"Galadon bootstrap: {env}") + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +def cmd_teardown(args: argparse.Namespace) -> int: + if not args.yes: + raise ConfigError("teardown removes what bootstrap provisioned; pass --yes to confirm") + root = _root(args.path) + config = load_config(root) + env = args.env or config.deploy.env + if env == "prod" and not args.yes_prod: + raise ConfigError("production teardown requires --yes-prod in the current session") + adapter = adapter_for(config.deploy.adapter) + ctx = DeployContext(root=root, config=config, env=env) + print(f"Galadon teardown: {env} -> {adapter.describe(ctx)}", file=sys.stderr) + result = adapter.teardown(ctx) + if args.json: + print(json.dumps(result, ensure_ascii=False, indent=2)) + else: + print(f"Galadon teardown: {env}") + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="galadon", description=__doc__) + parser.add_argument("--version", action="version", version=f"galadon {__version__}") + sub = parser.add_subparsers(dest="command", required=True) + + init = sub.add_parser("init", help="Initialize an engineering workspace.") + init.add_argument("--path", default=".", help="Target directory.") + init.add_argument("--name", default="", help="Project name; defaults to the directory name.") + init.add_argument("--template", choices=["web", "api", "static"], default="web") + init.add_argument("--role", choices=["solo", "six-ring", "orchestrator"], default="solo") + init.add_argument( + "--layout", + choices=list(LAYOUTS), + default="none", + help="Project layering contract; the default keeps 0.2.x behaviour.", + ) + init.add_argument( + "--stack", + choices=list(STACKS), + default="python", + help="Code skeleton and checks; only used together with --layout.", + ) + init.add_argument( + "--list-layouts", + action="store_true", + help="Print the layouts and stacks this build ships, then exit.", + ) + init.add_argument("--force", action="store_true", help="Add files to a non-empty directory.") + init.add_argument("--json", action="store_true") + init.set_defaults(func=cmd_init) + + next_cmd = sub.add_parser("next", help="Show the current phase and one next action.") + next_cmd.add_argument("--path", default=".") + next_cmd.add_argument("--json", action="store_true") + next_cmd.set_defaults(func=cmd_next) + + check = sub.add_parser("check", help="Run the current phase gate.") + check.add_argument("--path", default=".") + check.add_argument("--json", action="store_true") + check.set_defaults(func=cmd_check) + + new_cmd = sub.add_parser( + "new", help="Start a new iteration by archiving the previous release and check records." + ) + new_cmd.add_argument("--path", default=".") + new_cmd.add_argument("--json", action="store_true") + new_cmd.set_defaults(func=cmd_new) + + ship = sub.add_parser("ship", help="Build, verify, release and probe.") + ship.add_argument("--path", default=".") + ship.add_argument("--env", default="", help="Target environment; defaults to galadon.yaml.") + ship.add_argument("--dry-run", action="store_true") + ship.add_argument("--yes-prod", action="store_true", help="Confirm a production release.") + ship.add_argument("--json", action="store_true") + ship.set_defaults(func=cmd_ship) + + bootstrap = sub.add_parser("bootstrap", help="Provision the deployment target (adapter-defined).") + bootstrap.add_argument("--path", default=".") + bootstrap.add_argument("--env", default="") + bootstrap.add_argument("--yes-prod", action="store_true", help="Confirm production provisioning.") + bootstrap.add_argument("--json", action="store_true") + bootstrap.set_defaults(func=cmd_bootstrap) + + teardown = sub.add_parser("teardown", help="Remove what bootstrap provisioned (adapter-defined).") + teardown.add_argument("--path", default=".") + teardown.add_argument("--env", default="") + teardown.add_argument("--yes", action="store_true", help="Confirm the destructive teardown.") + teardown.add_argument("--yes-prod", action="store_true", help="Confirm production teardown.") + teardown.add_argument("--json", action="store_true") + teardown.set_defaults(func=cmd_teardown) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + return int(args.func(args)) + except (ConfigError, TemplateError, DeployError, FileNotFoundError) as exc: + print(f"galadon: {exc}", file=sys.stderr) + return 2 diff --git a/external_plugins/galadon/src/galadon/config.py b/external_plugins/galadon/src/galadon/config.py new file mode 100644 index 00000000..f66fd15d --- /dev/null +++ b/external_plugins/galadon/src/galadon/config.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path, PurePosixPath +from typing import Any + +import yaml + +LAYOUTS = ("none", "webapp", "service", "plugin") +STACKS = ("python", "node", "none") +TEMPLATES = ("web", "api", "static") + +# The shipped schema declares `additionalProperties: false` at the top level; +# the loader accepted anything, so a block that stopped meaning something — the +# retired `tools:` port — sat in real galadon.yaml files doing nothing. +TOP_LEVEL_KEYS = ( + "project", + "template", + "role", + "layout", + "stack", + "deploy", + "checks", + "paths", +) + +# Defaults are the 0.2.x paths: a project without a `paths:` block keeps the +# exact same artifacts and directories. +DEFAULT_PATHS = { + "spec": "spec/spec.md", + "evidence": "verify/evidence.md", + "retro": "wiki/retro.md", + "release": "release/release.json", +} + +_DEPLOY_FIELDS = { + "adapter", + "env", + "build", + "migrate", + "release", + "probe_url", + "probe_expect", + "artifact", + "options", +} +_SSH_REQUIRED = ("host", "user", "remote_root", "service", "artifact") + + +class ConfigError(ValueError): + pass + + +@dataclass +class DeployConfig: + adapter: str = "local" + env: str = "test" + build: str = "" + migrate: str = "" + release: str = "" + probe_url: str = "" + probe_expect: str = "" + artifact: str = "" + options: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class ChecksConfig: + build: str = "" + test: str = "" + timeout: int = 300 + + +@dataclass +class PathsConfig: + spec: Path = Path(DEFAULT_PATHS["spec"]) + evidence: Path = Path(DEFAULT_PATHS["evidence"]) + retro: Path = Path(DEFAULT_PATHS["retro"]) + release: Path = Path(DEFAULT_PATHS["release"]) + + +@dataclass +class ProjectConfig: + name: str + template: str + role: str + deploy: DeployConfig + checks: ChecksConfig + layout: str = "none" + stack: str = "none" + paths: PathsConfig = field(default_factory=PathsConfig) + + +def render_probe_expect(expect: str, commit: str | None) -> str: + if "{{COMMIT}}" not in expect: + return expect + if not commit: + raise ConfigError("deploy.probe_expect uses {{COMMIT}} but the project has no git commit") + return expect.replace("{{COMMIT}}", commit) + + +def _validate_ssh_options(options: dict[str, Any], artifact: str) -> None: + values = { + "host": options.get("host"), + "user": options.get("user"), + "remote_root": options.get("remote_root"), + "service": options.get("service"), + "artifact": artifact, + } + missing = [ + key + for key in _SSH_REQUIRED + if values[key] is None or not str(values[key]).strip() + ] + if missing: + raise ConfigError( + "galadon.yaml: deploy.adapter ssh requires host, user, remote_root, " + f"service, artifact; missing: {', '.join(missing)}" + ) + + host = str(values["host"]).strip() + user = str(values["user"]).strip() + service = str(values["service"]).strip() + remote_root = str(values["remote_root"]).strip().rstrip("/") + host_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.:-[]" + user_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-" + service_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.@-" + if not host[0].isalnum() or any(char not in host_chars for char in host): + raise ConfigError("galadon.yaml: deploy.options.host contains unsupported characters") + if not user[0].isalnum() or any(char not in user_chars for char in user): + raise ConfigError("galadon.yaml: deploy.options.user contains unsupported characters") + if not service[0].isalnum() or any(char not in service_chars for char in service): + raise ConfigError("galadon.yaml: deploy.options.service contains unsupported characters") + if not remote_root.startswith("/"): + raise ConfigError("galadon.yaml: deploy.options.remote_root must be an absolute path") + remote_root_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-/" + if any(char not in remote_root_chars for char in remote_root): + raise ConfigError( + "galadon.yaml: deploy.options.remote_root may only contain letters, digits, " + "dot, underscore, hyphen and slash" + ) + parts = PurePosixPath(remote_root).parts + if len(parts) < 3 or ".." in parts or "." in parts: + raise ConfigError( + "galadon.yaml: deploy.options.remote_root must be a specific directory below /" + ) + + +def _validate_path(value: str, key: str) -> Path: + if not value.strip(): + raise ConfigError(f"galadon.yaml: {key} must not be empty") + if "\\" in value: + raise ConfigError( + f"galadon.yaml: {key} must use forward slashes: {value}" + ) + path = PurePosixPath(value.strip()) + if path.is_absolute() or ".." in path.parts or path.parts in {(), (".",)}: + raise ConfigError( + f"galadon.yaml: {key} must be a relative path inside the project: {value}" + ) + return Path(*path.parts) + + +def _load_paths(raw: Any) -> PathsConfig: + if raw is None: + raw = {} + if not isinstance(raw, dict): + raise ConfigError("galadon.yaml: paths must be a mapping") + unknown = sorted(set(str(key) for key in raw) - set(DEFAULT_PATHS)) + if unknown: + raise ConfigError( + "galadon.yaml: paths has unknown keys: " + f"{', '.join(unknown)} (allowed: {', '.join(DEFAULT_PATHS)})" + ) + values: dict[str, str] = {} + for key, default in DEFAULT_PATHS.items(): + value = raw.get(key, default) + if not isinstance(value, str): + raise ConfigError(f"galadon.yaml: paths.{key} must be a string") + values[key] = value + return PathsConfig( + spec=_validate_path(values["spec"], "paths.spec"), + evidence=_validate_path(values["evidence"], "paths.evidence"), + retro=_validate_path(values["retro"], "paths.retro"), + release=_validate_path(values["release"], "paths.release"), + ) + + +def load_config(root: Path) -> ProjectConfig: + path = root / "galadon.yaml" + if not path.is_file(): + raise ConfigError(f"missing {path}; run `galadon init` first") + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except yaml.YAMLError as exc: + raise ConfigError(f"galadon.yaml is not valid YAML: {exc}") from exc + if not isinstance(data, dict): + raise ConfigError("galadon.yaml must contain a mapping") + + unknown = sorted({str(key) for key in data} - set(TOP_LEVEL_KEYS)) + if unknown: + raise ConfigError( + f"galadon.yaml: unknown top-level keys: {', '.join(unknown)} " + f"(allowed: {', '.join(TOP_LEVEL_KEYS)})" + ) + + name = str(data.get("project", "")).strip() + if not name: + raise ConfigError("galadon.yaml: project is required") + template = str(data.get("template", "")).strip() + if template not in TEMPLATES: + raise ConfigError("galadon.yaml: template must be web, api or static") + role = str(data.get("role", "solo")).strip() + if role not in {"solo", "six-ring", "orchestrator"}: + raise ConfigError("galadon.yaml: role must be solo, six-ring or orchestrator") + layout = str(data.get("layout", "none")).strip() or "none" + if layout not in LAYOUTS: + raise ConfigError(f"galadon.yaml: layout must be {', '.join(LAYOUTS)}") + stack = str(data.get("stack", "none")).strip() or "none" + if stack not in STACKS: + raise ConfigError(f"galadon.yaml: stack must be {', '.join(STACKS)}") + + deploy_raw = data.get("deploy") or {} + checks_raw = data.get("checks") or {} + if not isinstance(deploy_raw, dict): + raise ConfigError("galadon.yaml: deploy must be a mapping") + if not isinstance(checks_raw, dict): + raise ConfigError("galadon.yaml: checks must be a mapping") + + options_raw = deploy_raw.get("options", {}) + if options_raw is None: + options_raw = {} + if not isinstance(options_raw, dict): + raise ConfigError("galadon.yaml: deploy.options must be a mapping") + options = dict(options_raw) + for key, value in deploy_raw.items(): + if key not in _DEPLOY_FIELDS and key not in options: + options[str(key)] = value + + deploy = DeployConfig( + adapter=str(deploy_raw.get("adapter", "local")), + env=str(deploy_raw.get("env", "test")), + build=str(deploy_raw.get("build", "")), + migrate=str(deploy_raw.get("migrate", "")), + release=str(deploy_raw.get("release", "")), + probe_url=str(deploy_raw.get("probe_url", "")), + probe_expect=str(deploy_raw.get("probe_expect", "")), + artifact=str(deploy_raw.get("artifact", "")), + options=options, + ) + if deploy.adapter not in {"local", "ssh", "k8s"}: + raise ConfigError("galadon.yaml: deploy.adapter must be local, ssh or k8s") + if deploy.adapter == "ssh": + _validate_ssh_options(options, deploy.artifact) + + checks = ChecksConfig( + build=str(checks_raw.get("build") or ""), + test=str(checks_raw.get("test") or ""), + timeout=int(checks_raw.get("timeout") or 300), + ) + paths = _load_paths(data.get("paths")) + + return ProjectConfig( + name=name, + template=template, + role=role, + deploy=deploy, + checks=checks, + layout=layout, + stack=stack, + paths=paths, + ) diff --git a/external_plugins/galadon/src/galadon/gates.py b/external_plugins/galadon/src/galadon/gates.py new file mode 100644 index 00000000..c7fd459d --- /dev/null +++ b/external_plugins/galadon/src/galadon/gates.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from .config import ProjectConfig +from .layouts import MISSING_CHECKS, layout_contract_problems +from .loop import CHECKS_PATH, retro_ok, spec_ok +from .util import now_iso, run_shell, write_json + + +def _tail(text: str, limit: int = 2000) -> str: + text = text.strip() + return text[-limit:] if len(text) > limit else text + + +def _write_evidence(root: Path, config: ProjectConfig, report: dict[str, Any]) -> None: + lines = [ + "# Verification evidence", + "", + f"- at: {report['at']}", + f"- ok: {report['ok']}", + "", + ] + for item in report["results"]: + lines.extend( + [ + f"## {item['id']}", + "", + f"- command: `{item['command']}`", + f"- exit: {item['code']}", + ] + ) + if item.get("stdout"): + lines.extend(["", "```text", item["stdout"], "```"]) + if item.get("stderr"): + lines.extend(["", "```text", item["stderr"], "```"]) + lines.append("") + path = root / config.paths.evidence + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8") + + +def layout_result(root: Path, config: ProjectConfig) -> dict[str, Any]: + problems = layout_contract_problems(root, config) + detail = ( + f"layout contract ({config.layout}) is complete" + if not problems + else f"layout contract ({config.layout}) missing: " + "; ".join(problems) + ) + return { + "id": "layout", + "command": "", + "code": 0 if not problems else 1, + "stdout": detail, + "stderr": "", + } + + +def run_checks(root: Path, config: ProjectConfig) -> dict[str, Any]: + results: list[dict[str, Any]] = [] + ok, detail = spec_ok(root, config) + results.append({"id": "spec", "command": "", "code": 0 if ok else 1, "stdout": detail, "stderr": ""}) + + if ok and config.layout != "none": + layout = layout_result(root, config) + results.append(layout) + if layout["code"] != 0: + ok = False + + if ok: + commands = ( + ("build", config.checks.build.strip()), + ("test", config.checks.test.strip()), + ) + if not any(command for _, command in commands): + ok = False + results.append( + { + "id": "checks", + "command": "", + "code": 1, + "stdout": MISSING_CHECKS, + "stderr": "", + } + ) + else: + for check_id, command in commands: + if not command: + ok = False + results.append( + { + "id": check_id, + "command": "", + "code": 1, + "stdout": ( + f"checks.{check_id} is not configured — " + "declare checks.build/checks.test" + ), + "stderr": "", + } + ) + continue + code, stdout, stderr = run_shell(command, root, timeout=config.checks.timeout) + results.append( + { + "id": check_id, + "command": command, + "code": code, + "stdout": _tail(stdout), + "stderr": _tail(stderr), + } + ) + if code != 0: + ok = False + + report = { + "ok": ok, + "at": now_iso(), + "phase": "verify", + "results": results, + } + write_json(root / CHECKS_PATH, report) + _write_evidence(root, config, report) + return report + + +def check_learn(root: Path, config: ProjectConfig) -> dict[str, Any]: + ok, detail = retro_ok(root, config) + results: list[dict[str, Any]] = [ + { + "id": "learn", + "command": "", + "code": 0 if ok else 1, + "stdout": detail, + "stderr": "", + } + ] + if config.layout != "none": + layout = layout_result(root, config) + results.append(layout) + if layout["code"] != 0: + ok = False + return { + "ok": ok, + "at": now_iso(), + "phase": "learn", + "results": results, + } diff --git a/external_plugins/galadon/src/galadon/layouts.py b/external_plugins/galadon/src/galadon/layouts.py new file mode 100644 index 00000000..036c683f --- /dev/null +++ b/external_plugins/galadon/src/galadon/layouts.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path + +from .config import LAYOUTS, STACKS, ProjectConfig + +MISSING_CHECKS = "no checks configured — declare checks.build/checks.test" + +# A retro is short-term progress, so every real layout keeps it next to the +# progress notes instead of at the wiki root. +LAYOUT_RETRO = "wiki/progress/retro.md" + +AGENT_SLOTS = ("plans", "evidence", "archive", "skills", "tmp", "wiki") + + +@dataclass(frozen=True) +class LayoutSpec: + creates: str + node_entry: str | None + python_roots: tuple[str, ...] = () + shell_checks: tuple[str, ...] = () + # Layout whose stack templates this one renders. `service` and `webapp` + # ship the same app skeleton; keeping two byte-identical copies meant a fix + # to one silently left the other stale, so `service` reads `webapp`'s. + stack_templates: str | None = None + + +# The layout vocabulary was sampled from three unrelated production projects in +# different stacks; each already used the same nine layers. +LAYOUT_SPECS: dict[str, LayoutSpec] = { + "none": LayoutSpec( + creates="base template only (default); no layout contract", + node_entry=None, + ), + "webapp": LayoutSpec( + creates=( + "server/ src/ (python) web/ e2e/ tests/ scripts/ deploy/ + openspec/ spec/ " + "wiki/{knowledge,progress,runbook}/ .agents/" + ), + node_entry="server/index.js", + python_roots=("src", "server"), + ), + "service": LayoutSpec( + stack_templates="webapp", + creates=( + "server/ src/ (python) tests/ scripts/ deploy/ + openspec/ spec/ " + "wiki/{knowledge,progress,runbook}/ .agents/" + ), + node_entry="server/index.js", + python_roots=("src", "server"), + ), + "plugin": LayoutSpec( + creates=( + "bin/ src// skills// docs/ tests/ scripts/ + " + "openspec/ spec/ wiki/{knowledge,progress,runbook}/ .agents/" + ), + node_entry="src/index.js", + python_roots=("src",), + # The plugin ships a bash wrapper under bin/, so the build also + # syntax-checks it instead of trusting a file no compiler reads. + shell_checks=("bash -n bin/{bin}",), + ), +} + +# One table for every stack's real commands. `{roots}` is filled from the +# layout's python source directories, `{shell}` from its shell checks and +# `{entry}` from its node entry. `-f` is deliberate: without it compileall +# skips a file whose mtime moved within the same second, which made a broken +# entry point pass right after a green check. +STACK_CHECKS: dict[str, tuple[str, str]] = { + "python": ( + "python3 -m compileall -f -q {roots}{shell}", + "python3 -m unittest discover -s tests -v", + ), + "node": ("node --check {entry}{shell}", "npm test"), + "none": ("", ""), +} + +# The spec truth layer: one living index, one behaviour file per domain, one +# test matrix and one constitution. Each path is named individually in the gate. +SPEC_TRUTH_FILES = ( + "spec/INDEX.md", + "spec/TESTING.md", + "spec/CONSTITUTION.md", +) +CONTRACT_FILES = ("AGENTS.md", "openspec/config.yaml", *SPEC_TRUTH_FILES) +CONTRACT_DIRS = ( + "spec", + "spec/domains", + "wiki/knowledge", + "wiki/progress", + "wiki/runbook", + *(f".agents/{slot}" for slot in AGENT_SLOTS), +) +CONTRACT_IGNORES = ("out", ".agents") + + +def package_name(name: str) -> str: + cleaned = re.sub(r"[^0-9A-Za-z_]", "_", name).strip("_").lower() + if not cleaned: + cleaned = "app" + return f"_{cleaned}" if cleaned[0].isdigit() else cleaned + + +def npm_name(name: str) -> str: + cleaned = re.sub(r"[^a-z0-9._~-]+", "-", name.lower()).strip("-") + if not cleaned: + cleaned = "app" + return f"app{cleaned}" if cleaned[0] in "._" else cleaned + + +def checks_for(layout: str, stack: str, name: str = "") -> tuple[str, str]: + if layout not in LAYOUT_SPECS: + raise ValueError(f"unknown layout: {layout}") + if stack not in STACK_CHECKS: + raise ValueError(f"unknown stack: {stack}") + if stack == "none": + return ("", "") + spec = LAYOUT_SPECS[layout] + build, test = STACK_CHECKS[stack] + shell = "" + if spec.shell_checks: + if not name: + raise ValueError(f"layout {layout} shell checks need the project name") + shell = "".join( + f" && {command.format(bin=npm_name(name))}" + for command in spec.shell_checks + ) + if stack == "python": + if not spec.python_roots: + raise ValueError(f"layout {layout} has no python sources") + build = build.format(roots=" ".join(spec.python_roots), shell=shell) + else: + entry = spec.node_entry + if entry is None: + raise ValueError(f"layout {layout} has no node entry") + build = build.format(entry=entry, shell=shell) + return (build, test) + + +def layout_listing() -> str: + lines = ["layouts (galadon init --layout):"] + for name in LAYOUTS: + lines.append(f" {name:<8} {LAYOUT_SPECS[name].creates}") + lines.extend(["", "stacks (galadon init --stack):"]) + for stack in STACKS: + build, test = STACK_CHECKS[stack] + if stack == "none": + lines.append( + f' none no checks: galadon check fails with "{MISSING_CHECKS}" ' + "once the spec is complete" + ) + continue + display = ( + build.replace("{roots}", "") + .replace("{entry}", "") + .replace("{shell}", " && ") + ) + note = " (package.json scripts.test = node --test tests/)" if stack == "node" else "" + lines.append(f" {stack:<8} build: {display} | test: {test}{note}") + return "\n".join(lines) + + +def _gitignore_entries(root: Path) -> set[str]: + path = root / ".gitignore" + if not path.is_file(): + return set() + entries = set() + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + entries.add(line.lstrip("/").rstrip("/")) + return entries + + +def layout_contract_problems(root: Path, config: ProjectConfig) -> list[str]: + problems: list[str] = [] + for name in CONTRACT_FILES: + if not (root / name).is_file(): + problems.append(name) + for name in CONTRACT_DIRS: + if not (root / name).is_dir(): + problems.append(f"{name}/") + if not config.checks.build.strip() or not config.checks.test.strip(): + problems.append(MISSING_CHECKS) + ignored = _gitignore_entries(root) + for name in CONTRACT_IGNORES: + if name not in ignored: + problems.append(f".gitignore must ignore {name}/") + return problems diff --git a/external_plugins/galadon/src/galadon/loop.py b/external_plugins/galadon/src/galadon/loop.py new file mode 100644 index 00000000..94ee0d05 --- /dev/null +++ b/external_plugins/galadon/src/galadon/loop.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +from .config import ProjectConfig +from .roles import role_policy_for +from .util import git_dirty_paths, now_iso, read_json, write_json + +CHECKS_PATH = Path(".galadon/checks.json") + +ACTIONS = { + "build": "Implement the spec, then run `galadon check`.", + "verify": "Fix the failed checks, then run `galadon check` again.", + "ship": "Run `galadon ship --env ` after checking `--dry-run`.", + "done": "Loop complete. Start the next spec.", +} + + +def _text(path: Path) -> str: + return path.read_text(encoding="utf-8") if path.is_file() else "" + + +def actions_for(config: ProjectConfig) -> dict[str, str]: + return { + "spec": ( + f"Write {config.paths.spec}: problem, acceptance criteria and non-goals." + ), + **ACTIONS, + "learn": f"Write {config.paths.retro}, then run `galadon check`.", + } + + +def spec_ok(root: Path, config: ProjectConfig) -> tuple[bool, str]: + relative = config.paths.spec + text = _text(root / relative) + if not text: + return False, f"{relative} is missing" + if "TODO" in text: + return False, f"{relative} still contains TODO" + for heading in ("## Problem", "## Acceptance", "## Non-goals"): + if heading not in text: + return False, f"{relative} is missing {heading}" + if not re.search(r"^- \[[ xX]\] ", text, flags=re.MULTILINE): + return False, f"{relative} needs at least one acceptance checkbox" + return True, "spec is complete" + + +def retro_ok(root: Path, config: ProjectConfig) -> tuple[bool, str]: + relative = config.paths.retro + text = _text(root / relative) + if not text: + return False, f"{relative} is missing" + if "TODO" in text: + return False, f"{relative} still contains TODO" + for heading in ("## What changed", "## Next"): + if heading not in text: + return False, f"{relative} is missing {heading}" + return True, "retro is complete" + + +def uncommitted_paths(root: Path, config: ProjectConfig) -> list[str]: + """Changes that would ship under a commit they are not part of. + + The loop's own files are rewritten by `check`/`ship`: the evidence file, the + release record, the state directory and the phase block in `AGENTS.md`. + """ + paths = git_dirty_paths(root) + if paths is None: + return [] + generated = { + config.paths.evidence.as_posix(), + config.paths.release.as_posix(), + "AGENTS.md", + } + return sorted( + path + for path in paths + if path not in generated and not path.startswith(".galadon/") + ) + + +def infer_phase(root: Path, config: ProjectConfig) -> str: + if not spec_ok(root, config)[0]: + return "spec" + checks = read_json(root / CHECKS_PATH, None) + if checks is None: + return "build" + if not isinstance(checks, dict) or checks.get("ok") is not True: + return "verify" + release = read_json(root / config.paths.release, None) + if not isinstance(release, dict) or release.get("ok") is not True: + return "ship" + if not retro_ok(root, config)[0]: + return "learn" + return "done" + + +def role_hint(phase: str, config: ProjectConfig) -> str: + return " + ".join(role_policy_for(config.role).assign(phase)) + + +def next_payload(root: Path, config: ProjectConfig) -> dict[str, Any]: + phase = infer_phase(root, config) + return { + "project": config.name, + "phase": phase, + "role": role_hint(phase, config), + "next": actions_for(config)[phase], + } + + +def update_agents_block(root: Path, payload: dict[str, Any]) -> None: + path = root / "AGENTS.md" + if not path.is_file(): + return + text = path.read_text(encoding="utf-8") + block = ( + "\n" + f"- phase: {payload['phase']}\n" + f"- role: {payload['role']}\n" + f"- next: {payload['next']}\n" + "" + ) + pattern = re.compile(r".*?", re.DOTALL) + if pattern.search(text): + text = pattern.sub(block, text) + else: + text = text.rstrip() + "\n\n" + block + "\n" + path.write_text(text, encoding="utf-8") + + +def save_state(root: Path, payload: dict[str, Any]) -> None: + data = { + "updated_at": now_iso(), + "phase": payload.get("phase"), + "role": payload.get("role"), + "next": payload.get("next"), + } + write_json(root / ".galadon" / "state.json", data) + + +def dump_payload(payload: dict[str, Any]) -> str: + return json.dumps(payload, ensure_ascii=False, indent=2) diff --git a/external_plugins/galadon/src/galadon/ports.py b/external_plugins/galadon/src/galadon/ports.py new file mode 100644 index 00000000..e8380f6b --- /dev/null +++ b/external_plugins/galadon/src/galadon/ports.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal + +from .config import ProjectConfig + +Phase = Literal["spec", "build", "verify", "ship", "learn", "done"] + + +@dataclass +class Artifact: + path: Path + digest: str + + +@dataclass +class Release: + env: str + artifact: Artifact + adapter: str + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class ProbeResult: + ok: bool + detail: str + url: str | None = None + actual: str | None = None + + +@dataclass +class DeployContext: + root: Path + config: ProjectConfig + env: str + commit: str | None = None diff --git a/external_plugins/galadon/src/galadon/roles.py b/external_plugins/galadon/src/galadon/roles.py new file mode 100644 index 00000000..27e099fd --- /dev/null +++ b/external_plugins/galadon/src/galadon/roles.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from .ports import Phase + + +class SoloPolicy: + def assign(self, phase: Phase) -> list[str]: + return ["agent"] + + +class SixRingPolicy: + _mapping = { + "spec": ["spec"], + "build": ["dev"], + "verify": ["test", "check"], + "ship": ["git"], + "learn": ["spec", "check"], + "done": ["agent"], + } + + def assign(self, phase: Phase) -> list[str]: + return list(self._mapping.get(phase, ["agent"])) + + +class OrchestratorPolicy: + _mapping = { + "spec": ["orchestrator", "spec"], + "build": ["orchestrator", "worker"], + "verify": ["orchestrator", "test"], + "ship": ["orchestrator", "release"], + "learn": ["orchestrator", "check"], + "done": ["orchestrator"], + } + + def assign(self, phase: Phase) -> list[str]: + return list(self._mapping.get(phase, ["orchestrator"])) + + +_POLICIES = { + "solo": SoloPolicy, + "six-ring": SixRingPolicy, + "orchestrator": OrchestratorPolicy, +} + + +def role_policy_for(name: str) -> SoloPolicy | SixRingPolicy | OrchestratorPolicy: + return _POLICIES[name]() diff --git a/external_plugins/galadon/src/galadon/templates.py b/external_plugins/galadon/src/galadon/templates.py new file mode 100644 index 00000000..593c8334 --- /dev/null +++ b/external_plugins/galadon/src/galadon/templates.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import shutil +from datetime import date +from pathlib import Path + +from .config import DEFAULT_PATHS, LAYOUTS, STACKS +from .layouts import LAYOUT_RETRO, LAYOUT_SPECS, checks_for, npm_name, package_name + +PLUGIN_ROOT = Path(__file__).resolve().parents[2] + +# `.agents/` is gitignored inside the galadon repo itself, and npm drops every +# `.gitignore` from the published tarball, so templates store the leading dot +# under a neutral name and the renderer maps it back. +PATH_ALIASES = {"__agents__": ".agents", "gitignore": ".gitignore"} + +# Shared by every non-none layout: the spec truth layer plus the files that are +# byte-identical across layouts (`.agents/` slots and READMEs, the wiki / +# openspec / scripts READMEs, `.gitignore`, `openspec/config.yaml`). It renders +# before the layout's `common/` layer so a layout can override a file; `plugin` +# does, with its CLI/packaging-specific versions. +SHARED_LAYER = "_shared" + +# Base stubs a layout replaces with a real layer: layouts generate code under +# `src/` and move the retro into `wiki/progress/`, so the base README and the +# root retro stub would be wrong or duplicated. +LAYOUT_SUPERSEDES = frozenset({"src/README.md", "wiki/retro.md"}) + + +class TemplateError(ValueError): + pass + + +class FileTemplateProvider: + def __init__(self, plugin_root: Path | None = None) -> None: + self.plugin_root = plugin_root or PLUGIN_ROOT + + def _files(self, directory: Path) -> list[Path]: + if not directory.is_dir(): + return [] + return sorted(path for path in directory.rglob("*") if path.is_file()) + + def _relative(self, source: Path, layer: Path, values: dict[str, str]) -> Path: + parts: list[str] = [] + for part in source.relative_to(layer).parts: + part = PATH_ALIASES.get(part, part) + for key, value in values.items(): + part = part.replace(key, value) + if "{{" in part: + raise TemplateError(f"unknown placeholder in template path: {source}") + parts.append(part) + rendered = Path(*parts) + return rendered.with_suffix("") if source.suffix == ".tmpl" else rendered + + def render( + self, + root: Path, + profile: str, + role: str, + name: str, + force: bool = False, + layout: str = "none", + stack: str = "python", + ) -> dict[str, list[str]]: + if layout not in LAYOUTS: + raise TemplateError(f"unknown layout: {layout}") + if stack not in STACKS: + raise TemplateError(f"unknown stack: {stack}") + if "/" in name or "\\" in name: + raise TemplateError(f"project name must not contain a path separator: {name}") + if name in {".", ".."}: + raise TemplateError(f"project name must not be a path component: {name}") + effective_stack = stack if layout != "none" else "none" + + base = self.plugin_root / "templates" / "base" + overlay = self.plugin_root / "templates" / "profiles" / profile + if not base.is_dir(): + raise TemplateError(f"missing template base: {base}") + if not overlay.is_dir(): + raise TemplateError(f"unknown template profile: {profile}") + + # Explicit order: base, profile, shared, layout/common, layout/. + # Later layers win, so a layout may override a shared file; `none` + # never renders the shared layer. + layers: list[tuple[Path, bool]] = [(base, True), (overlay, False)] + if layout != "none": + shared = self.plugin_root / "templates" / "layouts" / SHARED_LAYER + if not shared.is_dir(): + raise TemplateError(f"missing shared layout template: {shared}") + layers.append((shared, False)) + layout_root = self.plugin_root / "templates" / "layouts" / layout + common = layout_root / "common" + if not common.is_dir(): + raise TemplateError(f"missing layout common template: {common}") + layers.append((common, False)) + if effective_stack != "none": + stack_owner = LAYOUT_SPECS[layout].stack_templates or layout + stack_dir = ( + self.plugin_root / "templates" / "layouts" / stack_owner / effective_stack + ) + if not stack_dir.is_dir(): + raise TemplateError( + f"missing template stack {effective_stack} for layout {layout}: {stack_dir}" + ) + layers.append((stack_dir, False)) + + build_check, test_check = checks_for(layout, effective_stack, name) + values = { + "{{PROJECT}}": name, + "{{PACKAGE}}": package_name(name), + "{{NPM_NAME}}": npm_name(name), + "{{TEMPLATE}}": profile, + "{{ROLE}}": role, + "{{LAYOUT}}": layout, + "{{STACK}}": effective_stack, + "{{RETRO}}": LAYOUT_RETRO if layout != "none" else DEFAULT_PATHS["retro"], + "{{BUILD_CHECK}}": build_check, + "{{TEST_CHECK}}": test_check, + "{{DATE}}": date.today().isoformat(), + } + + written: list[str] = [] + skipped: list[str] = [] + written_targets: set[Path] = set() + for layer, is_base in layers: + for source in self._files(layer): + relative = self._relative(source, layer, values) + if is_base and layout != "none" and relative.as_posix() in LAYOUT_SUPERSEDES: + continue + target = root / relative + if target.exists() and target not in written_targets and not force: + skipped.append(str(relative)) + continue + target.parent.mkdir(parents=True, exist_ok=True) + if source.suffix == ".tmpl": + text = source.read_text(encoding="utf-8") + for key, value in values.items(): + text = text.replace(key, value) + if "{{" in text: + raise TemplateError(f"unknown placeholder in template: {source}") + target.write_text(text, encoding="utf-8") + else: + shutil.copyfile(source, target) + target.chmod(source.stat().st_mode & 0o777) + written.append(str(relative)) + written_targets.add(target) + return {"written": sorted(set(written)), "skipped": sorted(set(skipped))} diff --git a/external_plugins/galadon/src/galadon/util.py b/external_plugins/galadon/src/galadon/util.py new file mode 100644 index 00000000..0aed7d14 --- /dev/null +++ b/external_plugins/galadon/src/galadon/util.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +def now_iso() -> str: + return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") + + +def read_json(path: Path, default: Any = None) -> Any: + if not path.is_file(): + return default + return json.loads(path.read_text(encoding="utf-8")) + + +def write_json(path: Path, data: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def digest_path(path: Path) -> str: + if path.is_file(): + return sha256_file(path) + digest = hashlib.sha256() + for child in sorted(path.rglob("*")): + if child.is_file(): + digest.update(child.relative_to(path).as_posix().encode("utf-8")) + digest.update(b"\0") + with child.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + digest.update(b"\0") + return digest.hexdigest() + + +def git_commit(root: Path) -> str | None: + try: + proc = subprocess.run( + ["git", "-C", str(root), "rev-parse", "--short", "HEAD"], + capture_output=True, + text=True, + check=False, + ) + except OSError: + return None + return proc.stdout.strip() if proc.returncode == 0 else None + + +def git_dirty_paths(root: Path) -> list[str] | None: + """Tracked paths with uncommitted changes, or None when git cannot answer.""" + try: + proc = subprocess.run( + ["git", "-C", str(root), "status", "--porcelain", "--untracked-files=no"], + capture_output=True, + text=True, + check=False, + ) + except OSError: + return None + if proc.returncode != 0: + return None + paths: list[str] = [] + for line in proc.stdout.splitlines(): + if len(line) < 4: + continue + path = line[3:].strip().strip('"') + if " -> " in path: + path = path.split(" -> ", 1)[1].strip().strip('"') + paths.append(path) + return paths + + +def run_shell( + command: str, + cwd: Path, + timeout: int = 300, + env: dict[str, str] | None = None, +) -> tuple[int, str, str]: + merged = os.environ.copy() + if env: + merged.update(env) + try: + proc = subprocess.run( + command, + cwd=str(cwd), + shell=True, + capture_output=True, + text=True, + timeout=timeout, + env=merged, + check=False, + ) + return proc.returncode, proc.stdout, proc.stderr + except subprocess.TimeoutExpired as exc: + stdout = exc.stdout or "" + stderr = exc.stderr or "" + if isinstance(stdout, bytes): + stdout = stdout.decode(errors="replace") + if isinstance(stderr, bytes): + stderr = stderr.decode(errors="replace") + return 124, stdout, stderr + f"\ncommand timed out after {timeout}s" diff --git a/external_plugins/galadon/templates/base/.env.example b/external_plugins/galadon/templates/base/.env.example new file mode 100644 index 00000000..102fa2dc --- /dev/null +++ b/external_plugins/galadon/templates/base/.env.example @@ -0,0 +1,2 @@ +# Variable names only. Put real values in .env or a secret provider. +EXAMPLE_API_TOKEN= diff --git a/external_plugins/galadon/templates/base/AGENTS.md.tmpl b/external_plugins/galadon/templates/base/AGENTS.md.tmpl new file mode 100644 index 00000000..0bcd3bcd --- /dev/null +++ b/external_plugins/galadon/templates/base/AGENTS.md.tmpl @@ -0,0 +1,41 @@ +# {{PROJECT}} + +## Start here + +1. Read `galadon.yaml`. +2. Run `galadon next`. +3. Do only the current phase, write its artifact, then pass its gate. + +## Current iteration + + +- phase: spec +- role: agent +- next: Write spec/spec.md: problem, acceptance criteria and non-goals. + + +## Rules + +- Do not write business code before the spec gate passes. +- Do not ship before verification passes. +- Production release requires explicit confirmation in the current session. +- Never put secret values in `galadon.yaml` or git. +- Fail loud: no silent fallback, no partial-success claim. + +## Artifacts + +| Phase | Write | Gate | +|---|---|---| +| spec | `spec/spec.md` | acceptance is testable | +| build | code and tests | build checks pass | +| verify | `verify/evidence.md` | tests and review pass | +| ship | `release/release.json` | probe matches | +| learn | `wiki/retro.md` | archived and docs updated | + +## Commands + +```bash +galadon next +galadon check +galadon ship --env test +``` diff --git a/external_plugins/galadon/templates/base/README.md.tmpl b/external_plugins/galadon/templates/base/README.md.tmpl new file mode 100644 index 00000000..fbd06129 --- /dev/null +++ b/external_plugins/galadon/templates/base/README.md.tmpl @@ -0,0 +1,18 @@ +# {{PROJECT}} + +Created with Galadon on {{DATE}}. + +The engineering loop is: + +```text +Spec -> Build -> Verify -> Ship -> Learn +``` + +Start with: + +```bash +galadon next +``` + +Project configuration lives in `galadon.yaml`. Secrets belong in `.env` or the +secret provider you configure, never in the YAML file. diff --git a/external_plugins/galadon/templates/base/deploy/README.md b/external_plugins/galadon/templates/base/deploy/README.md new file mode 100644 index 00000000..f090a236 --- /dev/null +++ b/external_plugins/galadon/templates/base/deploy/README.md @@ -0,0 +1,12 @@ +# Deploy + +The `local` adapter is a reference implementation: it runs configured build, +migrate and release commands, then requires a real `probe_url`. Releases live in +`deploy.options.root` (default `.galadon/deploy`) as immutable +`releases/-` directories, with an atomic `current` switch, a +retained `previous` and a `shared/deploy.lock`. A failed probe restores +`previous` and re-probes it. + +The `ssh` adapter is built in: set `deploy.adapter: ssh` in `galadon.yaml`. To +connect Kubernetes, implement `DeployPort` and register a new adapter. Do not +fake success by removing the probe. diff --git a/external_plugins/galadon/templates/base/galadon.yaml.tmpl b/external_plugins/galadon/templates/base/galadon.yaml.tmpl new file mode 100644 index 00000000..ae8c885c --- /dev/null +++ b/external_plugins/galadon/templates/base/galadon.yaml.tmpl @@ -0,0 +1,30 @@ +project: {{PROJECT}} +template: {{TEMPLATE}} +role: {{ROLE}} +layout: {{LAYOUT}} +stack: {{STACK}} + +paths: + # Stage artifacts. `retro` lives beside short-term progress in a layout; + # a project without a layout keeps the 0.2.x wiki/retro.md. + spec: spec/spec.md + evidence: verify/evidence.md + retro: {{RETRO}} + release: release/release.json + +deploy: + adapter: local + env: test + build: "" + migrate: "" + release: "" + probe_url: "" + probe_expect: "" + artifact: "" + +checks: + # Required: set the real commands. Empty values make `galadon check` fail + # loudly instead of passing a do-nothing gate. + build: "{{BUILD_CHECK}}" + test: "{{TEST_CHECK}}" + timeout: 300 diff --git a/external_plugins/galadon/templates/base/gitignore b/external_plugins/galadon/templates/base/gitignore new file mode 100644 index 00000000..69dfe6ee --- /dev/null +++ b/external_plugins/galadon/templates/base/gitignore @@ -0,0 +1,10 @@ +.agents/ +.galadon/ +.env +.env.* +!.env.example +__pycache__/ +*.pyc +node_modules/ +dist/ +build/ diff --git a/external_plugins/galadon/templates/base/release/README.md b/external_plugins/galadon/templates/base/release/README.md new file mode 100644 index 00000000..5e128a04 --- /dev/null +++ b/external_plugins/galadon/templates/base/release/README.md @@ -0,0 +1,4 @@ +# Release + +`release.json` is generated by `galadon ship`. It records the target environment, +artifact digest, commit and probe result. diff --git a/external_plugins/galadon/templates/base/scripts/quality-check.sh b/external_plugins/galadon/templates/base/scripts/quality-check.sh new file mode 100755 index 00000000..f59a5462 --- /dev/null +++ b/external_plugins/galadon/templates/base/scripts/quality-check.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +exec galadon check "$@" diff --git a/external_plugins/galadon/templates/base/spec/spec.md.tmpl b/external_plugins/galadon/templates/base/spec/spec.md.tmpl new file mode 100644 index 00000000..2df41ab9 --- /dev/null +++ b/external_plugins/galadon/templates/base/spec/spec.md.tmpl @@ -0,0 +1,13 @@ +# {{PROJECT}} spec + +## Problem + +TODO: what problem are we solving, for whom, and why now? + +## Acceptance + +- [ ] TODO: observable result that proves this works + +## Non-goals + +TODO: what we are explicitly not doing in this iteration diff --git a/external_plugins/galadon/templates/base/src/README.md b/external_plugins/galadon/templates/base/src/README.md new file mode 100644 index 00000000..c653e6a3 --- /dev/null +++ b/external_plugins/galadon/templates/base/src/README.md @@ -0,0 +1,3 @@ +# Source + +Put application code here. Galadon does not generate business logic. diff --git a/external_plugins/galadon/templates/base/verify/README.md b/external_plugins/galadon/templates/base/verify/README.md new file mode 100644 index 00000000..637f319c --- /dev/null +++ b/external_plugins/galadon/templates/base/verify/README.md @@ -0,0 +1,4 @@ +# Verification + +`evidence.md` is generated by `galadon check`. It records commands, exit codes +and output tails. Do not hand-edit evidence to make a failed check look green. diff --git a/external_plugins/galadon/templates/base/wiki/README.md b/external_plugins/galadon/templates/base/wiki/README.md new file mode 100644 index 00000000..39135480 --- /dev/null +++ b/external_plugins/galadon/templates/base/wiki/README.md @@ -0,0 +1,5 @@ +# Wiki + +- `knowledge/` holds durable decisions and pitfalls. +- `retro.md` closes each loop. +- Runbooks live beside the deploy adapter they describe. diff --git a/external_plugins/galadon/templates/base/wiki/retro.md.tmpl b/external_plugins/galadon/templates/base/wiki/retro.md.tmpl new file mode 100644 index 00000000..bb59f5ab --- /dev/null +++ b/external_plugins/galadon/templates/base/wiki/retro.md.tmpl @@ -0,0 +1,13 @@ +# {{PROJECT}} retro + +## What changed + +TODO: shipped behavior, version and evidence + +## What surprised us + +TODO: surprises, near misses and root causes + +## Next + +TODO: one or more concrete follow-ups diff --git a/external_plugins/galadon/templates/layouts/_shared/__agents__/README.md b/external_plugins/galadon/templates/layouts/_shared/__agents__/README.md new file mode 100644 index 00000000..8e16a040 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/_shared/__agents__/README.md @@ -0,0 +1,17 @@ +# Agent context (`.agents/`) + +Local runtime for agents. This directory is gitignored: nothing here is product +truth, and no conclusion in it counts until it moves into `wiki/`. + +| Slot | Holds | +|---|---| +| `plans/` | proposals, research, designs, review drafts | +| `wiki/` | project-local long-term knowledge and conclusions | +| `evidence/` | screenshots, recordings, verification and audit evidence | +| `skills/` | project-local scripts and reusable procedures | +| `tmp/` | temporary artifacts; should be filed within 14 days | +| `archive/` | records of finished tasks | + +Rules: new artifacts go into a slot, never flat in `.agents/`. When a task ends, +conclusions go to `wiki/`, evidence to `evidence/`, and the process stays in +`archive/`. diff --git a/external_plugins/galadon/templates/layouts/_shared/__agents__/archive/.gitkeep b/external_plugins/galadon/templates/layouts/_shared/__agents__/archive/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/external_plugins/galadon/templates/layouts/_shared/__agents__/evidence/.gitkeep b/external_plugins/galadon/templates/layouts/_shared/__agents__/evidence/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/external_plugins/galadon/templates/layouts/_shared/__agents__/plans/.gitkeep b/external_plugins/galadon/templates/layouts/_shared/__agents__/plans/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/external_plugins/galadon/templates/layouts/_shared/__agents__/skills/.gitkeep b/external_plugins/galadon/templates/layouts/_shared/__agents__/skills/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/external_plugins/galadon/templates/layouts/_shared/__agents__/tmp/.gitkeep b/external_plugins/galadon/templates/layouts/_shared/__agents__/tmp/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/external_plugins/galadon/templates/layouts/_shared/__agents__/wiki/.gitkeep b/external_plugins/galadon/templates/layouts/_shared/__agents__/wiki/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/external_plugins/galadon/templates/layouts/_shared/gitignore b/external_plugins/galadon/templates/layouts/_shared/gitignore new file mode 100644 index 00000000..0a759fd8 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/_shared/gitignore @@ -0,0 +1,11 @@ +.galadon/ +.agents/ +.env +.env.* +!.env.example +__pycache__/ +*.pyc +node_modules/ +dist/ +build/ +out/ diff --git a/external_plugins/galadon/templates/layouts/_shared/openspec/changes/README.md b/external_plugins/galadon/templates/layouts/_shared/openspec/changes/README.md new file mode 100644 index 00000000..b572715c --- /dev/null +++ b/external_plugins/galadon/templates/layouts/_shared/openspec/changes/README.md @@ -0,0 +1,8 @@ +# Changes + +One directory per change: `openspec/changes//` with `proposal.md`, +`tasks.md`, and `design.md` when a decision needs recording. + +A change is a construction ticket, not the source of truth: product behaviour is +written in `spec/spec.md`. When a change ships, fold the durable part into +`spec/` and `wiki/knowledge/`, and the state into `wiki/progress/`. diff --git a/external_plugins/galadon/templates/layouts/_shared/openspec/config.yaml.tmpl b/external_plugins/galadon/templates/layouts/_shared/openspec/config.yaml.tmpl new file mode 100644 index 00000000..0085784e --- /dev/null +++ b/external_plugins/galadon/templates/layouts/_shared/openspec/config.yaml.tmpl @@ -0,0 +1,15 @@ +schema: spec-driven + +# Shown to the agent when it creates a change. Keep it short. +context: | + {{PROJECT}} uses OpenSpec as the construction-plan layer, not as the product + truth. Product behaviour lives in spec/spec.md. A change starts as + openspec/changes// and passes `galadon check` before it ships. + +rules: + proposal: + - "Name the acceptance check the change must pass." + tasks: + - "Keep each task small enough to land in one commit." + design: + - "Record the alternatives you rejected and why." diff --git a/external_plugins/galadon/templates/layouts/_shared/openspec/specs/README.md b/external_plugins/galadon/templates/layouts/_shared/openspec/specs/README.md new file mode 100644 index 00000000..3c444137 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/_shared/openspec/specs/README.md @@ -0,0 +1,5 @@ +# Specs + +Long-lived capability specs, one file per capability. Keep a capability here +only once it is stable; before that it belongs in `openspec/changes/` as a +draft. diff --git a/external_plugins/galadon/templates/layouts/_shared/scripts/README.md b/external_plugins/galadon/templates/layouts/_shared/scripts/README.md new file mode 100644 index 00000000..7a8b0c0c --- /dev/null +++ b/external_plugins/galadon/templates/layouts/_shared/scripts/README.md @@ -0,0 +1,6 @@ +# Scripts + +- `dev.sh` — starts the backend locally; added by the stack layer. +- `quality-check.sh` — runs `galadon check` from any directory. + +Scripts stay small and fail loud: no silent fallbacks. diff --git a/external_plugins/galadon/templates/layouts/_shared/spec/CONSTITUTION.md.tmpl b/external_plugins/galadon/templates/layouts/_shared/spec/CONSTITUTION.md.tmpl new file mode 100644 index 00000000..456ce573 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/_shared/spec/CONSTITUTION.md.tmpl @@ -0,0 +1,49 @@ +# {{PROJECT}} constitution + +One page of hard rules. They outrank speed, convenience, and a green check that +only proves the tests still run. + +## Never change silently + +A change is silent when the diff does not say why and no artifact records the +new contract. Never change any of these without an explicit, reviewed change to +the matching `spec/` file: + +- A public contract: API shape, CLI flags and output, configuration keys, file + formats, environment variable names, storage schema, or error codes. +- A feature point's observable behaviour, acceptance criteria, or `GATE` + weight. +- A test that proves a feature point, `spec/TESTING.md`, or the check commands + in `galadon.yaml`. +- Deployment topology, release procedure, secret handling, or data retention. +- This constitution, `spec/INDEX.md`, or a domain file. + +## Requires review + +Review means a different person or agent reads the diff against the domain +file; a passing check is not a review. + +- Every `1w` row in `spec/INDEX.md`. +- A database or storage migration that can lose or rewrite data. +- Authentication, authorization, secrets, or personal data. +- A change that removes or weakens a test, or makes a check easier to pass. +- A new external dependency, a major upgrade, or a new deploy target. +- Deleting or renaming a feature point, domain file, or public path. +- Any change to this constitution. + +## Feature points + +A feature point without a testable acceptance criterion is not a feature point. +It is a wish: do not implement it, do not add it to `spec/INDEX.md`, and do not +count it as done. Testable means another person can run one named check or +observe one result and get an unambiguous pass or fail. + +## Precedence + +1. This constitution. +2. The domain file in `spec/domains/`. +3. `spec/INDEX.md`. +4. The code. + +When code and spec disagree, code is the bug until a reviewed spec change says +otherwise. diff --git a/external_plugins/galadon/templates/layouts/_shared/spec/INDEX.md.tmpl b/external_plugins/galadon/templates/layouts/_shared/spec/INDEX.md.tmpl new file mode 100644 index 00000000..8f2f0bab --- /dev/null +++ b/external_plugins/galadon/templates/layouts/_shared/spec/INDEX.md.tmpl @@ -0,0 +1,35 @@ +# Feature index + +`spec/INDEX.md` is the living index of {{PROJECT}}'s user-goal-level feature +points. It is not a task list and not a roadmap: a row exists only for a +feature point that has a behaviour contract in `spec/domains/` and a testable +acceptance criterion. + +## Rules + +- One row per user-goal-level feature point. A refactor, chore, bug fix, file + move, or internal abstraction is not a feature point. +- ID format: `F--01`, where `` is the domain slug in upper case + and the number is two digits starting at `01`. An ID never changes once it is + used. +- `GATE` records how reversible the change is: `1w` is a one-way door — a + decision that is expensive or impossible to undo — so it needs an explicit + review before implementation; `2w` is a two-way door and the default for a + reversible change. +- `主落点` points at the files that implement the feature point. It is a + pointer, not a guess: if the implementing files cannot be named, the feature + point is not ready for the index. +- A feature point must exist in `spec/domains/.md` and in this index + before implementation starts. Add both in the same change. +- Never add example, placeholder, or hypothetical rows. + +## Domain tables + +This index starts with no domains, so it starts with no rows. For each domain +file in `spec/domains/`, copy the header stub below once, replace `` +with the domain slug, and keep the four columns exactly as written. + +### + +| ID | Feature point | GATE | 主落点 | +|---|---|---|---| diff --git a/external_plugins/galadon/templates/layouts/_shared/spec/TESTING.md.tmpl b/external_plugins/galadon/templates/layouts/_shared/spec/TESTING.md.tmpl new file mode 100644 index 00000000..8eeb2ee6 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/_shared/spec/TESTING.md.tmpl @@ -0,0 +1,24 @@ +# Verification triggers + +This is {{PROJECT}}'s trigger matrix: if a change touches a row, run the named +check before review. The named checks are the ones declared in `galadon.yaml`; +this file does not invent commands. + +- `checks.test` is the test command declared in `galadon.yaml`. +- `checks.build` is the build command declared in `galadon.yaml`. +- `galadon check` runs the full current gate, including both. + +If `checks.test` does not cover the change, add a test first. An ad-hoc command +that happens to pass is not evidence and does not replace the project check. + +| If you change ... | You must run ... | Evidence to record | +|---|---|---| +| a backend route | `checks.build` and `checks.test` | the route test that fails without the change | +| the database schema | `checks.test`, including migration and rollback cases | migration test output and the restored schema | +| a UI component | `checks.test`, including render and interaction cases | component test output and the affected state | +| deployment config | `checks.build`, then `checks.test`, then `galadon check` | dry-run and gate output | +| docs only | `checks.test` and `galadon check` | the link or example check that proves the doc | + +A change that touches several rows runs the union. Before review, `galadon +check` must still pass: the matrix narrows what to run while working, it does +not replace the full gate. diff --git a/external_plugins/galadon/templates/layouts/_shared/spec/domains/README.md b/external_plugins/galadon/templates/layouts/_shared/spec/domains/README.md new file mode 100644 index 00000000..0cd6a76c --- /dev/null +++ b/external_plugins/galadon/templates/layouts/_shared/spec/domains/README.md @@ -0,0 +1,40 @@ +# Domains + +A domain is one area of user-visible behaviour with one owner file: +`spec/domains/.md`. The domain file is the behaviour truth; the code is +only the current implementation of what that file says. + +## Rules + +- One file per domain. The file name is the domain slug in lower case and the + same slug appears in feature-point IDs (`F--01`). +- A feature point must exist in its domain file and in `spec/INDEX.md` before + it is implemented. Both must agree; one without the other is not a feature + point. +- Describe behaviour, not code: goal, trigger, observable result, failure + behaviour, and how a person can prove it. +- Every entry carries an `impl` pointer to the files that implement it. The + pointer is part of the contract: move the implementation and update the + pointer in the same change. +- When the code and the domain file disagree, the domain file is the truth + until a reviewed change updates it. Treating the code as the truth is how a + behaviour change becomes silent. +- A domain file with no feature points is a placeholder: add its first entry + when the first feature point exists, or delete the file. + +## Entry format + +Copy this block for each feature point: + +```markdown +## F--01 — + +- goal: +- trigger: +- behaviour: +- failure: +- acceptance: +- impl: `` (and companion files) +``` + +An entry is not a feature point until its acceptance line is testable. diff --git a/external_plugins/galadon/templates/layouts/_shared/wiki/README.md b/external_plugins/galadon/templates/layouts/_shared/wiki/README.md new file mode 100644 index 00000000..a5636c9c --- /dev/null +++ b/external_plugins/galadon/templates/layouts/_shared/wiki/README.md @@ -0,0 +1,11 @@ +# Wiki + +Three directories, three lifetimes: + +- `knowledge/` — durable decisions, constraints and pitfalls that carry a why. +- `progress/` — current state: active branch, deployed version, what is in + flight, and `retro.md` at the end of a loop. +- `runbook/` — operations: how to run, deploy, roll back and read logs. + +Rule: branch or deploy state changes touch `progress/` only. The knowledge layer +does not move when the deployment does. diff --git a/external_plugins/galadon/templates/layouts/_shared/wiki/knowledge/README.md b/external_plugins/galadon/templates/layouts/_shared/wiki/knowledge/README.md new file mode 100644 index 00000000..8fc2b75b --- /dev/null +++ b/external_plugins/galadon/templates/layouts/_shared/wiki/knowledge/README.md @@ -0,0 +1,9 @@ +# Knowledge + +Durable knowledge only: designs that carry a why, constraints discovered the +hard way, dead ends with the reason they are dead, external limits. + +- Write the decision and the reason, not a copy of the code. +- One topic per file; link related files instead of repeating them. +- If it only describes the current branch or deploy, it belongs in + `../progress/`. diff --git a/external_plugins/galadon/templates/layouts/_shared/wiki/progress/README.md b/external_plugins/galadon/templates/layouts/_shared/wiki/progress/README.md new file mode 100644 index 00000000..eac82537 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/_shared/wiki/progress/README.md @@ -0,0 +1,10 @@ +# Progress + +Short-term state, updated whenever it changes: + +- the active branch and what is being built; +- the deployed version and environment; +- open risks and what is blocked; +- `retro.md` for the loop that just closed (written during `learn`). + +Do not move durable why into this directory; that belongs in `../knowledge/`. diff --git a/external_plugins/galadon/templates/layouts/_shared/wiki/runbook/README.md b/external_plugins/galadon/templates/layouts/_shared/wiki/runbook/README.md new file mode 100644 index 00000000..becfdfb9 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/_shared/wiki/runbook/README.md @@ -0,0 +1,10 @@ +# Runbook + +Operational recipes that must work under pressure: + +- start and stop the service locally; +- deploy to each environment, and roll back; +- where logs, evidence and build output live; +- the first three checks when a probe fails. + +Every command in this directory is expected to be copy-pasteable and current. diff --git a/external_plugins/galadon/templates/layouts/plugin/common/AGENTS.md.tmpl b/external_plugins/galadon/templates/layouts/plugin/common/AGENTS.md.tmpl new file mode 100644 index 00000000..38e1cf21 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/common/AGENTS.md.tmpl @@ -0,0 +1,68 @@ +# {{PROJECT}} + +Entry point for humans and agents. Each layer below has one job; start at the +top and open only what the current phase needs. + +## Start here + +1. Read `galadon.yaml` for checks, deploy settings and artifact paths. +2. Run `galadon next`; do only the current phase. +3. Run `galadon check` before `galadon ship`. + +## Where things live + +| Layer | Path | Holds | Committed | +|---|---|---|---| +| Entry | `AGENTS.md`, `README.md` | the index into every layer | yes | +| Plan | `openspec/{config.yaml,changes/,specs/}` | construction tickets | yes | +| Truth | `spec/` | product behaviour facts | yes | +| Knowledge | `wiki/{knowledge,progress,runbook}/` | durable why, current state, operations | yes | +| Context | `.agents/{plans,wiki,evidence,skills,tmp,archive}/` | agent runtime state | no | +| Code | `bin/`, `src/`, `skills/` | CLI, library and bundled skill | yes | +| Run | `scripts/`, `config.md` | dev script and configuration | yes | +| Verify | `tests/` | unit and CLI tests | yes | +| Output | `out/` | build output | no | + +## Knowledge ownership + +Project knowledge lives in `docs/` and `wiki/`; agent runtime state lives in +`.agents/` and is never committed; reusable cross-project knowledge lives in a +knowledge hub outside the repo — never keep a second copy here. + +## Current iteration + + +- phase: spec +- role: agent +- next: Write spec/spec.md: problem, acceptance criteria and non-goals. + + +## Artifacts + +| Phase | Write | Gate | +|---|---|---| +| spec | `spec/spec.md` | acceptance is testable | +| build | code and tests | build checks pass | +| verify | `verify/evidence.md` | tests and review pass | +| ship | `release/release.json` | probe matches | +| learn | `wiki/progress/retro.md` | archived and docs updated | + +## Rules + +- Do not write business code before the spec gate passes. +- Do not ship before verification passes. +- Branch and deploy state changes touch `wiki/progress/` only; `wiki/knowledge/` does not move. +- Never commit `.agents/` or `out/`. +- Never put secret values in `galadon.yaml` or git. +- Distribution manifests are a release decision: do not add placeholder ones. +- Fail loud: no silent fallback, no partial-success claim. + +## Commands + +```bash +# python/node stacks only +scripts/dev.sh ping --json +galadon next +galadon check +galadon ship --env test --dry-run +``` diff --git a/external_plugins/galadon/templates/layouts/plugin/common/README.md.tmpl b/external_plugins/galadon/templates/layouts/plugin/common/README.md.tmpl new file mode 100644 index 00000000..7f265ca3 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/common/README.md.tmpl @@ -0,0 +1,47 @@ +# {{PROJECT}} + +Created with Galadon on {{DATE}} using the `plugin` layout and the +`{{STACK}}` stack. + +This repository is an installable agent plugin: a CLI, a bundled skill and the +docs needed to package it. Distribution files (`.zcode-plugin/`, +`.codex-plugin/`, marketplace icons) are intentionally not generated. Packaging +is a separate gate, and a placeholder icon would pass a check it should fail. + +The engineering loop is: + +```text +Spec -> Build -> Verify -> Ship -> Learn +``` + +Start with: + +```bash +galadon next +``` + +## Layout + +```text +bin/{{NPM_NAME}} the runnable CLI +src/ library and CLI code ({{STACK}}) +skills/{{NPM_NAME}}/ bundled agent skill +tests/ unit and CLI tests +docs/ profile and packaging notes +openspec/ construction tickets +spec/ product behaviour facts +wiki/ knowledge / progress / runbook +.agents/ agent runtime, gitignored +scripts/dev.sh run the CLI from a checkout (python/node stacks) +``` + +With the `python` or `node` stack, `galadon.yaml` already declares real checks, +so `galadon check` is green on a fresh clone; `--stack none` ships no checks and +no `scripts/dev.sh`, and fails the gate. + +## Commands + +```bash +scripts/dev.sh ping --json +galadon check +``` diff --git a/external_plugins/galadon/templates/layouts/plugin/common/config.md.tmpl b/external_plugins/galadon/templates/layouts/plugin/common/config.md.tmpl new file mode 100644 index 00000000..3707b313 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/common/config.md.tmpl @@ -0,0 +1,29 @@ +# Configuration + +## Runtime + +The generated CLI has no required environment variables. Secrets belong in +`.env`; only variable names go in `.env.example`. + +## Local development + +With the `python` or `node` stack: + +```bash +scripts/dev.sh ping --json +bin/{{NPM_NAME}} --version +``` + +## Checks + +`galadon.yaml` declares the real commands: + +- build syntax-checks or compiles the source; +- test runs the test suite. + +`galadon check` runs both and writes `verify/evidence.md`. + +## Packaging + +Distribution metadata is not generated. Read `docs/packaging.md` before adding a +manifest, and keep every version in sync with the changelog. diff --git a/external_plugins/galadon/templates/layouts/plugin/common/docs/packaging.md b/external_plugins/galadon/templates/layouts/plugin/common/docs/packaging.md new file mode 100644 index 00000000..e3e1a6dc --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/common/docs/packaging.md @@ -0,0 +1,19 @@ +# Packaging + +This scaffold stops at the code: a runnable CLI and a bundled skill. Distribution +metadata is a release decision, not an init decision. + +To publish: + +1. Choose the channels and add only the manifests they need: + - DeepSeek Harness: `package.json` with `dsh.bundle` and `index.js`; + - ZCode: `.zcode-plugin/plugin.json` plus a 512x512 icon; + - Codex: `.codex-plugin/plugin.json`. +2. Keep `version` identical across every manifest and the changelog. +3. Put the skill at `skills//SKILL.md` with a description that states the + trigger conditions, not the implementation. +4. Run the full suite, then pack and inspect the tarball for bytecode and local + paths. + +Do not ship a placeholder icon or an empty manifest: both pass a naive check and +fail the first real install. diff --git a/external_plugins/galadon/templates/layouts/plugin/common/gitignore b/external_plugins/galadon/templates/layouts/plugin/common/gitignore new file mode 100644 index 00000000..19cecf3c --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/common/gitignore @@ -0,0 +1,12 @@ +.galadon/ +.agents/ +.env +.env.* +!.env.example +__pycache__/ +*.pyc +node_modules/ +dist/ +build/ +out/ +*.tgz diff --git a/external_plugins/galadon/templates/layouts/plugin/common/openspec/config.yaml.tmpl b/external_plugins/galadon/templates/layouts/plugin/common/openspec/config.yaml.tmpl new file mode 100644 index 00000000..9d9e10ab --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/common/openspec/config.yaml.tmpl @@ -0,0 +1,16 @@ +schema: spec-driven + +# Shown to the agent when it creates a change. Keep it short. +context: | + {{PROJECT}} is a distributable agent plugin. OpenSpec is the construction-plan + layer, not the product truth: product behaviour lives in spec/spec.md. A + change starts as openspec/changes// and passes `galadon check` + before it ships. + +rules: + proposal: + - "Name the acceptance check the change must pass." + tasks: + - "Keep each task small enough to land in one commit." + design: + - "Record the alternatives you rejected and why." diff --git a/external_plugins/galadon/templates/layouts/plugin/common/scripts/README.md b/external_plugins/galadon/templates/layouts/plugin/common/scripts/README.md new file mode 100644 index 00000000..3020078a --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/common/scripts/README.md @@ -0,0 +1,6 @@ +# Scripts + +- `dev.sh` — runs the CLI from a checkout; added by the stack layer. +- `quality-check.sh` — runs `galadon check` from any directory. + +Scripts stay small and fail loud: no silent fallbacks. diff --git a/external_plugins/galadon/templates/layouts/plugin/common/skills/{{NPM_NAME}}/SKILL.md.tmpl b/external_plugins/galadon/templates/layouts/plugin/common/skills/{{NPM_NAME}}/SKILL.md.tmpl new file mode 100644 index 00000000..59e76d96 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/common/skills/{{NPM_NAME}}/SKILL.md.tmpl @@ -0,0 +1,27 @@ +--- +name: {{NPM_NAME}} +description: Run the {{PROJECT}} command line plugin and report whether it is installed and working. Use when the user asks about {{PROJECT}} status, version or liveness. +--- + +# {{PROJECT}} + +## When to use + +- The user asks whether {{PROJECT}} is installed or working. +- The user asks for its version. +- Another skill needs a cheap liveness check before running a longer flow. + +## Commands + +```bash +{{NPM_NAME}} --version +{{NPM_NAME}} ping --json +``` + +`ping --json` prints `{"ok": true, ...}`; a non-zero exit means the plugin is +not usable, so report the failure instead of retrying blindly. + +## Limits + +This skill only reports liveness. It does not deploy, install or configure the +plugin; say so when the user asks for those. diff --git a/external_plugins/galadon/templates/layouts/plugin/common/spec/spec.md.tmpl b/external_plugins/galadon/templates/layouts/plugin/common/spec/spec.md.tmpl new file mode 100644 index 00000000..45637285 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/common/spec/spec.md.tmpl @@ -0,0 +1,19 @@ +# {{PROJECT}} spec + +## Problem + +{{PROJECT}} has no written behaviour contract yet: scope, acceptance and +non-goals live in chat history, so each change is judged differently. + +## Acceptance + +- [ ] `galadon check` passes on a fresh clone with the checks declared in `galadon.yaml` +- [ ] `bin/{{NPM_NAME}} ping --json` returns a JSON payload with `"ok": true` +- [ ] `skills/{{NPM_NAME}}/SKILL.md` states when an agent should use the plugin +- [ ] `docs/packaging.md` lists the steps to publish a first version + +## Non-goals + +- Choosing the first product feature (replace this spec with that feature) +- Publishing to a marketplace from this repository +- Placeholder distribution manifests or icons diff --git a/external_plugins/galadon/templates/layouts/plugin/common/wiki/README.md b/external_plugins/galadon/templates/layouts/plugin/common/wiki/README.md new file mode 100644 index 00000000..1d24abdd --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/common/wiki/README.md @@ -0,0 +1,11 @@ +# Wiki + +Three directories, three lifetimes: + +- `knowledge/` — durable decisions, constraints and pitfalls that carry a why. +- `progress/` — current state: active branch, released version, what is in + flight, and `retro.md` at the end of a loop. +- `runbook/` — operations: how to run, package, release and roll back. + +Rule: branch or release state changes touch `progress/` only. The knowledge +layer does not move when the release does. diff --git a/external_plugins/galadon/templates/layouts/plugin/common/wiki/knowledge/README.md b/external_plugins/galadon/templates/layouts/plugin/common/wiki/knowledge/README.md new file mode 100644 index 00000000..1b6a053c --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/common/wiki/knowledge/README.md @@ -0,0 +1,9 @@ +# Knowledge + +Durable knowledge only: designs that carry a why, constraints discovered the +hard way, dead ends with the reason they are dead, external limits. + +- Write the decision and the reason, not a copy of the code. +- One topic per file; link related files instead of repeating them. +- If it only describes the current branch or release, it belongs in + `../progress/`. diff --git a/external_plugins/galadon/templates/layouts/plugin/common/wiki/progress/README.md b/external_plugins/galadon/templates/layouts/plugin/common/wiki/progress/README.md new file mode 100644 index 00000000..9b1ef6b1 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/common/wiki/progress/README.md @@ -0,0 +1,10 @@ +# Progress + +Short-term state, updated whenever it changes: + +- the active branch and what is being built; +- the released version and where it is published; +- open risks and what is blocked; +- `retro.md` for the loop that just closed (written during `learn`). + +Do not move durable why into this directory; that belongs in `../knowledge/`. diff --git a/external_plugins/galadon/templates/layouts/plugin/common/wiki/runbook/README.md b/external_plugins/galadon/templates/layouts/plugin/common/wiki/runbook/README.md new file mode 100644 index 00000000..43af9611 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/common/wiki/runbook/README.md @@ -0,0 +1,10 @@ +# Runbook + +Operational recipes that must work under pressure: + +- run the CLI from a checkout; +- package a build and inspect the tarball; +- publish a version to each channel and roll it back; +- the first three checks when an install fails. + +Every command in this directory is expected to be copy-pasteable and current. diff --git a/external_plugins/galadon/templates/layouts/plugin/node/bin/{{NPM_NAME}}.tmpl b/external_plugins/galadon/templates/layouts/plugin/node/bin/{{NPM_NAME}}.tmpl new file mode 100755 index 00000000..dde3484f --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/node/bin/{{NPM_NAME}}.tmpl @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +HERE="$(cd -P "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec node "$HERE/src/index.js" "$@" diff --git a/external_plugins/galadon/templates/layouts/plugin/node/package.json.tmpl b/external_plugins/galadon/templates/layouts/plugin/node/package.json.tmpl new file mode 100644 index 00000000..c6c65bc5 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/node/package.json.tmpl @@ -0,0 +1,21 @@ +{ + "name": "{{NPM_NAME}}", + "version": "0.1.0", + "type": "module", + "description": "{{PROJECT}} agent plugin", + "bin": { + "{{NPM_NAME}}": "bin/{{NPM_NAME}}" + }, + "scripts": { + "test": "node --test tests/" + }, + "files": [ + "bin/", + "src/", + "skills/", + "README.md" + ], + "engines": { + "node": ">=20" + } +} diff --git a/external_plugins/galadon/templates/layouts/plugin/node/scripts/dev.sh.tmpl b/external_plugins/galadon/templates/layouts/plugin/node/scripts/dev.sh.tmpl new file mode 100755 index 00000000..026d0497 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/node/scripts/dev.sh.tmpl @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +exec node src/index.js "$@" diff --git a/external_plugins/galadon/templates/layouts/plugin/node/src/index.js.tmpl b/external_plugins/galadon/templates/layouts/plugin/node/src/index.js.tmpl new file mode 100644 index 00000000..419458bd --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/node/src/index.js.tmpl @@ -0,0 +1,32 @@ +#!/usr/bin/env node +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +export const name = "{{NPM_NAME}}"; +export const version = "0.1.0"; + +export function ping() { + return { ok: true, plugin: name, version }; +} + +function usage() { + console.log(`${name} ${version}`); + console.log(`usage: ${name} ping [--json]`); +} + +export function main(argv = process.argv.slice(2)) { + if (argv[0] === "--version" || argv[0] === "-v") { + console.log(`${name} ${version}`); + return 0; + } + if (argv[0] === "ping") { + console.log(argv.includes("--json") ? JSON.stringify(ping()) : `${name} ok`); + return 0; + } + usage(); + return 2; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exitCode = main(); +} diff --git a/external_plugins/galadon/templates/layouts/plugin/node/tests/cli.test.js.tmpl b/external_plugins/galadon/templates/layouts/plugin/node/tests/cli.test.js.tmpl new file mode 100644 index 00000000..36b1814f --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/node/tests/cli.test.js.tmpl @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const entry = fileURLToPath(new URL("../src/index.js", import.meta.url)); + +test("ping returns a JSON liveness payload", () => { + const stdout = execFileSync(process.execPath, [entry, "ping", "--json"], { + encoding: "utf8", + }); + assert.deepEqual(JSON.parse(stdout), { + ok: true, + plugin: "{{NPM_NAME}}", + version: "0.1.0", + }); +}); + +test("--version prints the plugin name and version", () => { + const stdout = execFileSync(process.execPath, [entry, "--version"], { + encoding: "utf8", + }); + assert.equal(stdout.trim(), "{{NPM_NAME}} 0.1.0"); +}); diff --git a/external_plugins/galadon/templates/layouts/plugin/node/tests/index.js b/external_plugins/galadon/templates/layouts/plugin/node/tests/index.js new file mode 100644 index 00000000..3b2de1e2 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/node/tests/index.js @@ -0,0 +1,13 @@ +// Node 22+ treats a positional `--test` path as a file, not a directory, so +// `node --test tests/` resolves this module. Import every sibling test file so +// package.json keeps the documented `node --test tests/` script on both old and +// current Node. +import { readdirSync } from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const directory = fileURLToPath(new URL(".", import.meta.url)); +for (const file of readdirSync(directory).sort()) { + if (file.endsWith(".test.js")) { + await import(pathToFileURL(`${directory}${file}`).href); + } +} diff --git a/external_plugins/galadon/templates/layouts/plugin/python/bin/{{NPM_NAME}}.tmpl b/external_plugins/galadon/templates/layouts/plugin/python/bin/{{NPM_NAME}}.tmpl new file mode 100755 index 00000000..9e5ea859 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/python/bin/{{NPM_NAME}}.tmpl @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +HERE="$(cd -P "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PYTHONPATH="$HERE/src${PYTHONPATH:+:$PYTHONPATH}" exec python3 -m {{PACKAGE}} "$@" diff --git a/external_plugins/galadon/templates/layouts/plugin/python/scripts/dev.sh.tmpl b/external_plugins/galadon/templates/layouts/plugin/python/scripts/dev.sh.tmpl new file mode 100755 index 00000000..ddc67bef --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/python/scripts/dev.sh.tmpl @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +exec "bin/{{NPM_NAME}}" "$@" diff --git a/external_plugins/galadon/templates/layouts/plugin/python/src/{{PACKAGE}}/__init__.py.tmpl b/external_plugins/galadon/templates/layouts/plugin/python/src/{{PACKAGE}}/__init__.py.tmpl new file mode 100644 index 00000000..8a87f72b --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/python/src/{{PACKAGE}}/__init__.py.tmpl @@ -0,0 +1,5 @@ +"""{{PROJECT}} agent plugin package.""" + +from __future__ import annotations + +__version__ = "0.1.0" diff --git a/external_plugins/galadon/templates/layouts/plugin/python/src/{{PACKAGE}}/__main__.py b/external_plugins/galadon/templates/layouts/plugin/python/src/{{PACKAGE}}/__main__.py new file mode 100644 index 00000000..fb8cc47c --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/python/src/{{PACKAGE}}/__main__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .cli import main + +raise SystemExit(main()) diff --git a/external_plugins/galadon/templates/layouts/plugin/python/src/{{PACKAGE}}/cli.py.tmpl b/external_plugins/galadon/templates/layouts/plugin/python/src/{{PACKAGE}}/cli.py.tmpl new file mode 100644 index 00000000..e12d860f --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/python/src/{{PACKAGE}}/cli.py.tmpl @@ -0,0 +1,31 @@ +"""Command line entry point for {{PROJECT}}.""" + +from __future__ import annotations + +import argparse +import json +from typing import Sequence + +from . import __version__ + +PLUGIN = "{{NPM_NAME}}" + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog=PLUGIN, description="{{PROJECT}} agent plugin.") + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + sub = parser.add_subparsers(dest="command") + ping = sub.add_parser("ping", help="Print a liveness payload.") + ping.add_argument("--json", action="store_true") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + if args.command == "ping": + payload = {"ok": True, "plugin": PLUGIN, "version": __version__} + print(json.dumps(payload) if args.json else f"{PLUGIN} ok") + return 0 + parser.print_help() + return 2 diff --git a/external_plugins/galadon/templates/layouts/plugin/python/tests/test_cli.py.tmpl b/external_plugins/galadon/templates/layouts/plugin/python/tests/test_cli.py.tmpl new file mode 100644 index 00000000..59058be1 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/plugin/python/tests/test_cli.py.tmpl @@ -0,0 +1,42 @@ +from __future__ import annotations + +import io +import json +import os +import subprocess +import sys +import unittest +from contextlib import redirect_stdout +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from {{PACKAGE}}.cli import main # noqa: E402 + + +class CliTests(unittest.TestCase): + def test_ping_json_names_the_plugin(self) -> None: + buffer = io.StringIO() + with redirect_stdout(buffer): + code = main(["ping", "--json"]) + self.assertEqual(code, 0) + self.assertEqual( + json.loads(buffer.getvalue()), + {"ok": True, "plugin": "{{NPM_NAME}}", "version": "0.1.0"}, + ) + + def test_module_entrypoint_prints_version(self) -> None: + env = {**os.environ, "PYTHONPATH": str(ROOT / "src")} + proc = subprocess.run( + [sys.executable, "-m", "{{PACKAGE}}", "--version"], + capture_output=True, + text=True, + env=env, + ) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(proc.stdout.strip(), "{{NPM_NAME}} 0.1.0") + + +if __name__ == "__main__": + unittest.main() diff --git a/external_plugins/galadon/templates/layouts/service/common/AGENTS.md.tmpl b/external_plugins/galadon/templates/layouts/service/common/AGENTS.md.tmpl new file mode 100644 index 00000000..36321228 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/service/common/AGENTS.md.tmpl @@ -0,0 +1,65 @@ +# {{PROJECT}} + +Entry point for humans and agents. Each layer below has one job; start at the +top and open only what the current phase needs. + +## Start here + +1. Read `galadon.yaml` for checks, deploy settings and artifact paths. +2. Run `galadon next`; do only the current phase. +3. Run `galadon check` before `galadon ship`. + +## Where things live + +| Layer | Path | Holds | Committed | +|---|---|---|---| +| Entry | `AGENTS.md`, `README.md` | the index into every layer | yes | +| Plan | `openspec/{config.yaml,changes/,specs/}` | construction tickets | yes | +| Truth | `spec/` | product behaviour facts | yes | +| Knowledge | `wiki/{knowledge,progress,runbook}/` | durable why, current state, operations | yes | +| Context | `.agents/{plans,wiki,evidence,skills,tmp,archive}/` | agent runtime state | no | +| Code | `server/` | the service | yes | +| Run | `scripts/`, `deploy/`, `config.md` | scripts, deploy and environment | yes | +| Verify | `tests/` | unit and integration tests | yes | +| Output | `out/` | build output | no | + +## Knowledge ownership + +Project knowledge lives in `docs/` and `wiki/`; agent runtime state lives in +`.agents/` and is never committed; reusable cross-project knowledge lives in a +knowledge hub outside the repo — never keep a second copy here. + +## Current iteration + + +- phase: spec +- role: agent +- next: Write spec/spec.md: problem, acceptance criteria and non-goals. + + +## Artifacts + +| Phase | Write | Gate | +|---|---|---| +| spec | `spec/spec.md` | acceptance is testable | +| build | code and tests | build checks pass | +| verify | `verify/evidence.md` | tests and review pass | +| ship | `release/release.json` | probe matches | +| learn | `wiki/progress/retro.md` | archived and docs updated | + +## Rules + +- Do not write business code before the spec gate passes. +- Do not ship before verification passes. +- Branch and deploy state changes touch `wiki/progress/` only; `wiki/knowledge/` does not move. +- Never commit `.agents/` or `out/`. +- Never put secret values in `galadon.yaml` or git. +- Fail loud: no silent fallback, no partial-success claim. + +## Commands + +```bash +galadon next +galadon check +galadon ship --env test --dry-run +``` diff --git a/external_plugins/galadon/templates/layouts/service/common/README.md.tmpl b/external_plugins/galadon/templates/layouts/service/common/README.md.tmpl new file mode 100644 index 00000000..f5797cbf --- /dev/null +++ b/external_plugins/galadon/templates/layouts/service/common/README.md.tmpl @@ -0,0 +1,43 @@ +# {{PROJECT}} + +Created with Galadon on {{DATE}} using the `service` layout and the +`{{STACK}}` stack. + +The engineering loop is: + +```text +Spec -> Build -> Verify -> Ship -> Learn +``` + +Start with: + +```bash +galadon next +``` + +## Layout + +```text +server/ the service ({{STACK}}) +tests/ unit tests +openspec/ construction tickets +spec/ product behaviour facts +wiki/ knowledge / progress / runbook +.agents/ agent runtime, gitignored +scripts/dev.sh run the service locally (python/node stacks) +config.md environment and configuration notes +deploy/ deploy notes for the adapter in galadon.yaml +``` + +With the `python` or `node` stack, `galadon.yaml` already declares real checks, +so `galadon check` is green on a fresh clone; `--stack none` ships no checks and +no `scripts/dev.sh`, and fails the gate. Secrets belong in `.env` or the secret +provider you configure, never in the YAML file. + +## Commands + +```bash +scripts/dev.sh +galadon next +galadon check +``` diff --git a/external_plugins/galadon/templates/layouts/service/common/config.md b/external_plugins/galadon/templates/layouts/service/common/config.md new file mode 100644 index 00000000..2884b4a2 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/service/common/config.md @@ -0,0 +1,32 @@ +# Configuration and environment + +## Runtime + +- `HOST` (default `127.0.0.1`): bind address. +- `PORT` (default `8000`): service port. +- Secrets belong in `.env`; only variable names go in `.env.example`. + +## Local development + +With the `python` or `node` stack: + +```bash +scripts/dev.sh +curl http://127.0.0.1:8000/health.json +``` + +## Checks + +`galadon.yaml` declares the real commands: + +- build compiles or syntax-checks the service; +- test runs the test suite. + +`galadon check` runs both and writes `verify/evidence.md`. + +## Deploy + +`deploy.adapter` is `local` by default and publishes under `.galadon/deploy`. +The `ssh` adapter targets a host with systemd and a reverse proxy; the option +shape is documented in the Galadon README. Do not put credentials in +`galadon.yaml`. diff --git a/external_plugins/galadon/templates/layouts/service/common/deploy/README.md b/external_plugins/galadon/templates/layouts/service/common/deploy/README.md new file mode 100644 index 00000000..c54584ef --- /dev/null +++ b/external_plugins/galadon/templates/layouts/service/common/deploy/README.md @@ -0,0 +1,12 @@ +# Deploy + +`galadon.yaml` selects the deployment adapter; this directory holds what only +this project can describe, not credentials. + +- `local` publishes into `.galadon/deploy` with immutable releases, an atomic + `current` switch, a retained `previous`, and a deploy lock. +- `ssh` targets a host with systemd and a reverse proxy; its settings live under + `deploy.options`. + +Never put secret values here. A failed probe rolls back to `previous` and exits +non-zero; do not fake a successful deploy by removing the probe. diff --git a/external_plugins/galadon/templates/layouts/service/common/spec/spec.md.tmpl b/external_plugins/galadon/templates/layouts/service/common/spec/spec.md.tmpl new file mode 100644 index 00000000..71d46c0f --- /dev/null +++ b/external_plugins/galadon/templates/layouts/service/common/spec/spec.md.tmpl @@ -0,0 +1,18 @@ +# {{PROJECT}} spec + +## Problem + +{{PROJECT}} has no written behaviour contract yet: scope, acceptance and +non-goals live in chat history, so each change is judged differently. + +## Acceptance + +- [ ] `galadon check` passes on a fresh clone with the checks declared in `galadon.yaml` +- [ ] `GET /health.json` returns `{"ok": true, "project": "{{PROJECT}}", "version": "0.1.0"}` +- [ ] with the `python` or `node` stack, `scripts/dev.sh` starts the service locally + +## Non-goals + +- Choosing the first product feature (replace this spec with that feature) +- Backend framework selection (add it together with the dependency) +- Production credentials and deploy targets diff --git a/external_plugins/galadon/templates/layouts/webapp/common/AGENTS.md.tmpl b/external_plugins/galadon/templates/layouts/webapp/common/AGENTS.md.tmpl new file mode 100644 index 00000000..d859d94e --- /dev/null +++ b/external_plugins/galadon/templates/layouts/webapp/common/AGENTS.md.tmpl @@ -0,0 +1,65 @@ +# {{PROJECT}} + +Entry point for humans and agents. Each layer below has one job; start at the +top and open only what the current phase needs. + +## Start here + +1. Read `galadon.yaml` for checks, deploy settings and artifact paths. +2. Run `galadon next`; do only the current phase. +3. Run `galadon check` before `galadon ship`. + +## Where things live + +| Layer | Path | Holds | Committed | +|---|---|---|---| +| Entry | `AGENTS.md`, `README.md` | the index into every layer | yes | +| Plan | `openspec/{config.yaml,changes/,specs/}` | construction tickets | yes | +| Truth | `spec/` | product behaviour facts | yes | +| Knowledge | `wiki/{knowledge,progress,runbook}/` | durable why, current state, operations | yes | +| Context | `.agents/{plans,wiki,evidence,skills,tmp,archive}/` | agent runtime state | no | +| Code | `server/`, `web/` | backend and frontend | yes | +| Run | `scripts/`, `deploy/`, `config.md` | scripts, deploy and environment | yes | +| Verify | `tests/`, `e2e/` | unit and end-to-end tests | yes | +| Output | `out/` | build output | no | + +## Knowledge ownership + +Project knowledge lives in `docs/` and `wiki/`; agent runtime state lives in +`.agents/` and is never committed; reusable cross-project knowledge lives in a +knowledge hub outside the repo — never keep a second copy here. + +## Current iteration + + +- phase: spec +- role: agent +- next: Write spec/spec.md: problem, acceptance criteria and non-goals. + + +## Artifacts + +| Phase | Write | Gate | +|---|---|---| +| spec | `spec/spec.md` | acceptance is testable | +| build | code and tests | build checks pass | +| verify | `verify/evidence.md` | tests and review pass | +| ship | `release/release.json` | probe matches | +| learn | `wiki/progress/retro.md` | archived and docs updated | + +## Rules + +- Do not write business code before the spec gate passes. +- Do not ship before verification passes. +- Branch and deploy state changes touch `wiki/progress/` only; `wiki/knowledge/` does not move. +- Never commit `.agents/` or `out/`. +- Never put secret values in `galadon.yaml` or git. +- Fail loud: no silent fallback, no partial-success claim. + +## Commands + +```bash +galadon next +galadon check +galadon ship --env test --dry-run +``` diff --git a/external_plugins/galadon/templates/layouts/webapp/common/README.md.tmpl b/external_plugins/galadon/templates/layouts/webapp/common/README.md.tmpl new file mode 100644 index 00000000..7804844c --- /dev/null +++ b/external_plugins/galadon/templates/layouts/webapp/common/README.md.tmpl @@ -0,0 +1,44 @@ +# {{PROJECT}} + +Created with Galadon on {{DATE}} using the `webapp` layout and the +`{{STACK}}` stack. + +The engineering loop is: + +```text +Spec -> Build -> Verify -> Ship -> Learn +``` + +Start with: + +```bash +galadon next +``` + +## Layout + +```text +server/ backend ({{STACK}}) +web/ frontend landing zone; pick the framework here +e2e/ end-to-end tests +tests/ unit tests +openspec/ construction tickets +spec/ product behaviour facts +wiki/ knowledge / progress / runbook +.agents/ agent runtime, gitignored +scripts/dev.sh run the backend locally (python/node stacks) +config.md environment and configuration notes +``` + +With the `python` or `node` stack, `galadon.yaml` already declares real checks, +so `galadon check` is green on a fresh clone; `--stack none` ships no checks and +no `scripts/dev.sh`, and fails the gate. Secrets belong in `.env` or the secret +provider you configure, never in the YAML file. + +## Commands + +```bash +scripts/dev.sh +galadon next +galadon check +``` diff --git a/external_plugins/galadon/templates/layouts/webapp/common/config.md b/external_plugins/galadon/templates/layouts/webapp/common/config.md new file mode 100644 index 00000000..54461ab2 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/webapp/common/config.md @@ -0,0 +1,32 @@ +# Configuration and environment + +## Runtime + +- `HOST` (default `127.0.0.1`): backend bind address. +- `PORT` (default `8000`): backend port. +- Secrets belong in `.env`; only variable names go in `.env.example`. + +## Local development + +With the `python` or `node` stack: + +```bash +scripts/dev.sh +curl http://127.0.0.1:8000/health.json +``` + +## Checks + +`galadon.yaml` declares the real commands: + +- build compiles or syntax-checks the backend; +- test runs the test suite. + +`galadon check` runs both and writes `verify/evidence.md`. + +## Deploy + +`deploy.adapter` is `local` by default and publishes under `.galadon/deploy`. +The `ssh` adapter targets a host with systemd and a reverse proxy; the option +shape is documented in the Galadon README. Do not put credentials in +`galadon.yaml`. diff --git a/external_plugins/galadon/templates/layouts/webapp/common/deploy/README.md b/external_plugins/galadon/templates/layouts/webapp/common/deploy/README.md new file mode 100644 index 00000000..c54584ef --- /dev/null +++ b/external_plugins/galadon/templates/layouts/webapp/common/deploy/README.md @@ -0,0 +1,12 @@ +# Deploy + +`galadon.yaml` selects the deployment adapter; this directory holds what only +this project can describe, not credentials. + +- `local` publishes into `.galadon/deploy` with immutable releases, an atomic + `current` switch, a retained `previous`, and a deploy lock. +- `ssh` targets a host with systemd and a reverse proxy; its settings live under + `deploy.options`. + +Never put secret values here. A failed probe rolls back to `previous` and exits +non-zero; do not fake a successful deploy by removing the probe. diff --git a/external_plugins/galadon/templates/layouts/webapp/common/e2e/README.md b/external_plugins/galadon/templates/layouts/webapp/common/e2e/README.md new file mode 100644 index 00000000..d30e315b --- /dev/null +++ b/external_plugins/galadon/templates/layouts/webapp/common/e2e/README.md @@ -0,0 +1,8 @@ +# End-to-end tests + +Browser or full-stack tests that exercise a running system. They are not part of +`checks.test` because they need a live server; run them explicitly once +`scripts/dev.sh` (python/node stacks) is up. + +Keep one test per user-visible acceptance criterion from `spec/spec.md`, and +record a run in `wiki/progress/` when it matters. diff --git a/external_plugins/galadon/templates/layouts/webapp/common/spec/spec.md.tmpl b/external_plugins/galadon/templates/layouts/webapp/common/spec/spec.md.tmpl new file mode 100644 index 00000000..cb6ff455 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/webapp/common/spec/spec.md.tmpl @@ -0,0 +1,18 @@ +# {{PROJECT}} spec + +## Problem + +{{PROJECT}} has no written behaviour contract yet: scope, acceptance and +non-goals live in chat history, so each change is judged differently. + +## Acceptance + +- [ ] `galadon check` passes on a fresh clone with the checks declared in `galadon.yaml` +- [ ] `GET /health.json` returns `{"ok": true, "project": "{{PROJECT}}", "version": "0.1.0"}` +- [ ] `AGENTS.md` points every layer at a real path + +## Non-goals + +- Choosing the first product feature (replace this spec with that feature) +- Frontend framework selection (see `web/README.md`) +- Production credentials and deploy targets diff --git a/external_plugins/galadon/templates/layouts/webapp/common/web/README.md b/external_plugins/galadon/templates/layouts/webapp/common/web/README.md new file mode 100644 index 00000000..8fbef911 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/webapp/common/web/README.md @@ -0,0 +1,8 @@ +# Web + +Frontend landing zone. Galadon deliberately does not pick a framework for you. + +- Keep the API contract in `spec/spec.md`, not in component code. +- The backend serves `GET /health.json`; proxy it during development. +- Unit tests live next to the code; end-to-end tests go in `../e2e/`. +- Build output goes to `../out/`, which is gitignored. diff --git a/external_plugins/galadon/templates/layouts/webapp/node/package.json.tmpl b/external_plugins/galadon/templates/layouts/webapp/node/package.json.tmpl new file mode 100644 index 00000000..7418cfca --- /dev/null +++ b/external_plugins/galadon/templates/layouts/webapp/node/package.json.tmpl @@ -0,0 +1,14 @@ +{ + "name": "{{NPM_NAME}}", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "{{PROJECT}} backend", + "scripts": { + "start": "node server/index.js", + "test": "node --test tests/" + }, + "engines": { + "node": ">=20" + } +} diff --git a/external_plugins/galadon/templates/layouts/webapp/node/scripts/dev.sh b/external_plugins/galadon/templates/layouts/webapp/node/scripts/dev.sh new file mode 100755 index 00000000..59c8c537 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/webapp/node/scripts/dev.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +exec node server/index.js "$@" diff --git a/external_plugins/galadon/templates/layouts/webapp/node/server/index.js.tmpl b/external_plugins/galadon/templates/layouts/webapp/node/server/index.js.tmpl new file mode 100644 index 00000000..c7ca7613 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/webapp/node/server/index.js.tmpl @@ -0,0 +1,39 @@ +import { createServer } from "node:http"; +import { pathToFileURL } from "node:url"; +import process from "node:process"; + +export const PROJECT = "{{PROJECT}}"; +export const VERSION = "0.1.0"; + +export function health() { + return { ok: true, project: PROJECT, version: VERSION }; +} + +export function createApp() { + return createServer((request, response) => { + if (request.url !== "/health.json") { + const body = JSON.stringify({ ok: false, error: "not found" }); + response.writeHead(404, { "content-type": "application/json" }); + response.end(body); + return; + } + const body = JSON.stringify(health()); + response.writeHead(200, { + "content-type": "application/json", + "content-length": Buffer.byteLength(body), + }); + response.end(body); + }); +} + +export function main() { + const host = process.env.HOST ?? "127.0.0.1"; + const port = Number(process.env.PORT ?? "8000"); + createApp().listen(port, host, () => { + console.log(`${PROJECT} listening on http://${host}:${port}`); + }); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/external_plugins/galadon/templates/layouts/webapp/node/tests/health.test.js.tmpl b/external_plugins/galadon/templates/layouts/webapp/node/tests/health.test.js.tmpl new file mode 100644 index 00000000..d5bd020e --- /dev/null +++ b/external_plugins/galadon/templates/layouts/webapp/node/tests/health.test.js.tmpl @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createApp, health } from "../server/index.js"; + +test("health payload names the project", () => { + assert.deepEqual(health(), { + ok: true, + project: "{{PROJECT}}", + version: "0.1.0", + }); +}); + +test("GET /health.json returns 200 JSON", async () => { + const server = createApp(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/health.json`); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.project, "{{PROJECT}}"); + } finally { + await new Promise((resolve) => server.close(resolve)); + } +}); diff --git a/external_plugins/galadon/templates/layouts/webapp/node/tests/index.js b/external_plugins/galadon/templates/layouts/webapp/node/tests/index.js new file mode 100644 index 00000000..3b2de1e2 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/webapp/node/tests/index.js @@ -0,0 +1,13 @@ +// Node 22+ treats a positional `--test` path as a file, not a directory, so +// `node --test tests/` resolves this module. Import every sibling test file so +// package.json keeps the documented `node --test tests/` script on both old and +// current Node. +import { readdirSync } from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const directory = fileURLToPath(new URL(".", import.meta.url)); +for (const file of readdirSync(directory).sort()) { + if (file.endsWith(".test.js")) { + await import(pathToFileURL(`${directory}${file}`).href); + } +} diff --git a/external_plugins/galadon/templates/layouts/webapp/python/scripts/dev.sh b/external_plugins/galadon/templates/layouts/webapp/python/scripts/dev.sh new file mode 100755 index 00000000..c10f0de8 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/webapp/python/scripts/dev.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +exec python3 server/app.py "$@" diff --git a/external_plugins/galadon/templates/layouts/webapp/python/server/app.py.tmpl b/external_plugins/galadon/templates/layouts/webapp/python/server/app.py.tmpl new file mode 100644 index 00000000..9f9dc540 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/webapp/python/server/app.py.tmpl @@ -0,0 +1,13 @@ +"""Run the backend from the repository root: python3 server/app.py.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from {{PACKAGE}}.app import main # noqa: E402 + +if __name__ == "__main__": + main() diff --git a/external_plugins/galadon/templates/layouts/webapp/python/src/{{PACKAGE}}/__init__.py.tmpl b/external_plugins/galadon/templates/layouts/webapp/python/src/{{PACKAGE}}/__init__.py.tmpl new file mode 100644 index 00000000..c4ce19f2 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/webapp/python/src/{{PACKAGE}}/__init__.py.tmpl @@ -0,0 +1,5 @@ +"""{{PROJECT}} backend package.""" + +from __future__ import annotations + +__version__ = "0.1.0" diff --git a/external_plugins/galadon/templates/layouts/webapp/python/src/{{PACKAGE}}/app.py.tmpl b/external_plugins/galadon/templates/layouts/webapp/python/src/{{PACKAGE}}/app.py.tmpl new file mode 100644 index 00000000..9db807c5 --- /dev/null +++ b/external_plugins/galadon/templates/layouts/webapp/python/src/{{PACKAGE}}/app.py.tmpl @@ -0,0 +1,48 @@ +"""HTTP surface for {{PROJECT}}. + +Standard library only: the scaffold must run before you pick a framework. +Replace the handler, keep `health()` real, and let `galadon check` stay green. +""" + +from __future__ import annotations + +import json +import os +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + +from . import __version__ + +PROJECT = "{{PROJECT}}" + + +def health() -> dict[str, Any]: + return {"ok": True, "project": PROJECT, "version": __version__} + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 + if self.path != "/health.json": + self.send_error(404, "not found") + return + body = json.dumps(health()).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, fmt: str, *args: Any) -> None: + return + + +def main() -> None: + host = os.environ.get("HOST", "127.0.0.1") + port = int(os.environ.get("PORT", "8000")) + server = ThreadingHTTPServer((host, port), Handler) + print(f"{PROJECT} listening on http://{host}:{port}") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/external_plugins/galadon/templates/layouts/webapp/python/tests/test_app.py.tmpl b/external_plugins/galadon/templates/layouts/webapp/python/tests/test_app.py.tmpl new file mode 100644 index 00000000..2be6726e --- /dev/null +++ b/external_plugins/galadon/templates/layouts/webapp/python/tests/test_app.py.tmpl @@ -0,0 +1,41 @@ +from __future__ import annotations + +import json +import sys +import threading +import unittest +from http.server import ThreadingHTTPServer +from pathlib import Path +from urllib.request import urlopen + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from {{PACKAGE}}.app import Handler, health # noqa: E402 + + +class HealthTests(unittest.TestCase): + def test_health_payload_names_the_project(self) -> None: + payload = health() + self.assertTrue(payload["ok"]) + self.assertEqual(payload["project"], "{{PROJECT}}") + self.assertEqual(payload["version"], "0.1.0") + + def test_health_endpoint_returns_json(self) -> None: + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + url = f"http://127.0.0.1:{server.server_port}/health.json" + with urlopen(url, timeout=5) as response: + self.assertEqual(response.status, 200) + self.assertEqual(response.headers["Content-Type"], "application/json") + payload = json.loads(response.read()) + finally: + server.shutdown() + server.server_close() + self.assertEqual(payload, health()) + + +if __name__ == "__main__": + unittest.main() diff --git a/external_plugins/galadon/templates/profiles/api/docs/profile.md b/external_plugins/galadon/templates/profiles/api/docs/profile.md new file mode 100644 index 00000000..853379c8 --- /dev/null +++ b/external_plugins/galadon/templates/profiles/api/docs/profile.md @@ -0,0 +1,4 @@ +# API profile + +This profile assumes a backend service. Keep the spec focused on request and +response behavior, errors and operational limits. diff --git a/external_plugins/galadon/templates/profiles/static/docs/profile.md b/external_plugins/galadon/templates/profiles/static/docs/profile.md new file mode 100644 index 00000000..1678106e --- /dev/null +++ b/external_plugins/galadon/templates/profiles/static/docs/profile.md @@ -0,0 +1,4 @@ +# Static profile + +This profile assumes a static site. The build output should be a directory; the +release adapter must copy it atomically and probe the public URL. diff --git a/external_plugins/galadon/templates/profiles/web/docs/profile.md b/external_plugins/galadon/templates/profiles/web/docs/profile.md new file mode 100644 index 00000000..328f2c84 --- /dev/null +++ b/external_plugins/galadon/templates/profiles/web/docs/profile.md @@ -0,0 +1,4 @@ +# Web profile + +This profile assumes a browser-facing product with a backend and a build step. +Set `checks.build` and `checks.test` in `galadon.yaml` to the real commands. diff --git a/marketplace.json b/marketplace.json index c7f09a06..171ebe0f 100644 --- a/marketplace.json +++ b/marketplace.json @@ -10,7 +10,10 @@ "url": "https://z.ai" }, "unlistedPlugins": [ - { "name": "openpencil", "source": "./external_plugins/openpencil" } + { + "name": "openpencil", + "source": "./external_plugins/openpencil" + } ], "plugins": [ { @@ -827,7 +830,9 @@ }, { "name": "excalidraw", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "Excalidraw", "displayName_i18n": { "en": "Excalidraw", @@ -850,7 +855,9 @@ "name": "photocraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/photocraft/icon.png", "source": "./external_plugins/photocraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "PhotoCraft", "displayName_i18n": { "en": "PhotoCraft", @@ -877,7 +884,9 @@ "name": "vectorcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/vectorcraft/icon.png", "source": "./external_plugins/vectorcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "VectorCraft", "displayName_i18n": { "en": "VectorCraft", @@ -904,7 +913,9 @@ "name": "pdfcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/pdfcraft/icon.png", "source": "./external_plugins/pdfcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "PdfCraft", "displayName_i18n": { "en": "PdfCraft", @@ -931,7 +942,9 @@ "name": "designcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/designcraft/icon.png", "source": "./external_plugins/designcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "DesignCraft", "displayName_i18n": { "en": "DesignCraft", @@ -958,7 +971,9 @@ "name": "wordcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/wordcraft/icon.png", "source": "./external_plugins/wordcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "WordCraft", "displayName_i18n": { "en": "WordCraft", @@ -985,7 +1000,9 @@ "name": "gridcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/gridcraft/icon.png", "source": "./external_plugins/gridcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "GridCraft", "displayName_i18n": { "en": "GridCraft", @@ -1012,7 +1029,9 @@ "name": "deckcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/deckcraft/icon.png", "source": "./external_plugins/deckcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "DeckCraft", "displayName_i18n": { "en": "DeckCraft", @@ -1039,7 +1058,9 @@ "name": "cadcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/cadcraft/icon.png", "source": "./external_plugins/cadcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "CADCraft", "displayName_i18n": { "en": "CADCraft", @@ -1066,7 +1087,9 @@ "name": "soundcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/soundcraft/icon.png", "source": "./external_plugins/soundcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "SoundCraft", "displayName_i18n": { "en": "SoundCraft", @@ -1149,7 +1172,9 @@ }, "category": "developer-tools", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/kicad/icon.png", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "KiCad", "displayName_i18n": { "en": "KiCad", @@ -1443,6 +1468,45 @@ "mcp", "verification" ] + }, + { + "name": "galadon", + "source": "./external_plugins/galadon", + "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/galadon/icon.png", + "description": "A five-stage engineering loop for agent-built projects: spec, build, verify, ship, learn, with a layout/stack scaffold generator. Pluggable role, template and deploy ports.", + "description_i18n": { + "en": "A five-stage engineering loop for agent-built projects: spec, build, verify, ship, learn, with a layout/stack scaffold generator. Pluggable role, template and deploy ports.", + "zh-CN": "面向 agent 工程的五阶段循环:规格、施工、验证、发布、收口;内置版式与技术栈的脚手架生成器;角色、模板与部署通过可替换端口接入。" + }, + "version": "0.3.4", + "author": { + "name": "Lumos-789", + "url": "https://github.com/Lumos-789" + }, + "category": "developer-tools", + "keywords": [ + "agent", + "workflow", + "scaffold", + "deploy", + "codex", + "zcode", + "dsh" + ], + "examplePrompts": [ + "Initialize this project with Galadon and show me the next step", + "Ship the current change to the test environment" + ], + "examplePrompts_i18n": { + "zh-CN": [ + "用 Galadon 初始化这个项目,告诉我下一步", + "把当前改动发布到测试环境" + ] + }, + "displayName": "Galadon", + "displayName_i18n": { + "zh-CN": "Galadon" + } } ] -} \ No newline at end of file +}