# Rewrite @ansible package for npm publish — Implementation Plan

**Goal:** Implement a publishable npm-scoped example package at packages/@ansible/example, add guarded GitHub Actions publish and deploy workflows (publish on v* tags or manual_dispatch; deploy to motief.sgeboers.nl via user webapps), CI pack-tests, and documentation for embedding and deployment secrets.

**Design:** see thoughts/shared/designs/2026-03-28-rewrite-ansible-package-design.md

Author: Sven Geboers

---

## Dependency Graph

```
Batch 1 (parallel): 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7
Batch 2 (parallel): 2.1, 2.2, 2.3, 2.4 [depends on Batch 1]
```

---

## Batch 1: Foundation (parallel - independent files)

All tasks in this batch have NO dependencies and can be created in parallel.

### Task 1.1: package.json for package
**File:** `packages/@ansible/example/package.json`
**Summary:** New package.json implementing an npm-scoped package @ansible/example. Exact choices made:
- name: "@ansible/example"
- version: "0.1.0"
- author: "Sven Geboers"
- publishConfig.access: "public"
- files: ["src/**", "README.md", "package.json"] (controls published files, avoids .npmignore)
- scripts:
  - test: `node tests/run.js`
  - pack-inspect: `node tests/test_pack_inspect.js`
  - prepublish:verify: `npm run pack-inspect` (keeps a named script for CI)
  - pack: `npm pack`
**Tests to add:** none in this file; tests below will exercise package.json
**Verify:**
- Local: cd packages/@ansible/example && node -e "console.log(require('./package.json').name)"  # should print @ansible/example
- Run package tests: cd packages/@ansible/example && npm test
**Effort:** 0.5h

### Task 1.2: README.md
**File:** `packages/@ansible/example/README.md`
**Summary:** Short README with package purpose, usage example, and author attribution line "Author: Sven Geboers". Include publish and deploy notes and reference to docs/*.md for deployment details.
**Tests to add:** none
**Verify:** open file or run: sed -n '1,40p' packages/@ansible/example/README.md
**Effort:** 0.5h

### Task 1.3: package entrypoint
**File:** `packages/@ansible/example/src/index.js`
**Summary:** Minimal, well-documented CommonJS module that exports a function used by tests. Keep runtime trivial (e.g., function hello(name){ return `hello ${name}` }). No dependencies.
**Tests to add:** used by unit test below
**Verify:** node -e "console.log(require('./packages/@ansible/example/src/index.js')('world'))"
**Effort:** 0.5h

### Task 1.4: unit test — package.json fields
**File:** `packages/@ansible/example/tests/test_package_json.js`
**Summary:** Node test that loads package.json and asserts required fields are present: name === "@ansible/example", version present, author === "Sven Geboers", publishConfig.access === "public", files array exists.
**Tests to add:** this is the test file
**Verify:** cd packages/@ansible/example && node tests/test_package_json.js (exit 0 on success)
**Effort:** 0.5h

### Task 1.5: pack-inspect test
**File:** `packages/@ansible/example/tests/test_pack_inspect.js`
**Summary:** Test that runs `npm pack` (in package dir) programmatically, captures the produced tarball name, asserts the tarball exists and contains package/package.json. Implementation notes: uses child_process.execSync and `tar -xOzf` to read package/package.json from the tarball and assert name/version match. This test requires `tar` on the runner (Linux/macOS). If `tar` not available, the test fails with clear message.
**Tests to add:** this is the test
**Verify:** cd packages/@ansible/example && node tests/test_pack_inspect.js
**Effort:** 1.0h

### Task 1.6: tiny package test runner
**File:** `packages/@ansible/example/tests/run.js`
**Summary:** Small node test harness that runs both test_package_json.js and test_pack_inspect.js, prints nice output, and returns non-zero on failure. Used by package.json test script to keep CI independent of external test runners.
**Tests to add:** none (this is the runner used by `npm test`)
**Verify:** cd packages/@ansible/example && node tests/run.js
**Effort:** 0.5h

### Task 1.7: pack-inspect helper (optional small script)
**File:** `packages/@ansible/example/tests/_pack_helpers.js`
**Summary:** Small utility used by test_pack_inspect.js to encapsulate npm pack and tar inspection logic. Keeps main test readable. (Kept internal/private to tests.)
**Tests to add:** none
**Verify:** run the tests which import it
**Effort:** 0.25h

---

## Batch 2: CI + Docs (parallel — depend on Batch 1)

All tasks in this batch depend on the package files being present (Batch 1).

### Task 2.1: GitHub Actions publish workflow
**File:** `.github/workflows/publish-ansible-example.yml`
**Summary:** New Actions workflow that performs build/test/pack/publish for packages/@ansible/example.
**Key behavior:**
- Triggers: push tags matching `v*` and workflow_dispatch (manual).
- Jobs:
  - verify: runs on all triggers: checks out repo, sets up Node 18 (LTS) using actions/setup-node, installs root-level dependencies if any (skipped if none), then runs `cd packages/@ansible/example && npm ci || true` (guard), then `npm test`. Then runs `npm pack` and verifies produced tarball (reuses test script). The verify job always runs and must pass before publish.
  - publish: runs only when `github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')` OR when manually triggered AND `secrets.NPM_TOKEN` exists. publish job is gated by `if: ${{ secrets.NPM_TOKEN != '' && ( github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') ) }}`.
    - On publish: write ephemeral ~/.npmrc with `//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}` (use `echo` and file permissions 0600), run `npm publish --access public` from package directory, then securely delete ~/.npmrc (shred if available or overwrite and rm). Use Actions mask to avoid logging secrets.

**Secrets required:** NPM_TOKEN (string)
**Tests to add:** none (workflow file)
**Verify:**
- Locally simulate: cd packages/@ansible/example && npm test && npm pack
- In Actions: push a non-production tag on a test repository with a safe NPM_TOKEN that points to a test registry OR run workflow_dispatch with a dry-run branch (see dry-run below)
**Effort:** 1.5h

### Task 2.2: GitHub Actions deploy workflow
**File:** `.github/workflows/deploy-to-vps.yml`
**Summary:** Workflow to prepare and run deployment to external VPS motief.sgeboers.nl using user `webapps`. Triggers: push to main and workflow_dispatch. The job is non-destructive by default and will not run remote commands unless DEPLOY_SSH_KEY or DEPLOY_PASSWORD secrets are present. It includes a dry-run/verify job that checks SSH connectivity without executing commands that change state.
**Key behavior:**
- Inputs/secrets used (recommended names): DEPLOY_HOST (default motief.sgeboers.nl), DEPLOY_USER (default webapps), DEPLOY_SSH_PORT (default 22), DEPLOY_SSH_KEY (private key, optional), DEPLOY_PASSWORD (optional fallback), DEPLOY_PATH (path to deploy, optional)
- Jobs:
  - dry-run-connect: attempts to verify connectivity. If DEPLOY_SSH_KEY present, create an ephemeral key file (0600), run `ssh -o BatchMode=yes -i $KEY -p $PORT $USER@$HOST 'echo connected'` and return success if the host echoes. This step is strictly a connection check (no file writes). If only DEPLOY_PASSWORD provided, the job will print instructions explaining the need for SSH key or use of a runner with sshpass (not recommended) and skip.
  - deploy: guarded `if: steps.check_secrets.outputs.has_creds == 'true'` — only runs when credentials are present. Steps: checkout, optionally build artifacts, create ephemeral SSH key file, rsync or scp artifact to $USER@$HOST:$DEPLOY_PATH, run non-destructive remote commands (e.g., systemctl --user status <unit> or echo) only if DEPLOY_CONFIRM=true input is set. By default the job performs no destructive operations; it's expected operator sets inputs when ready.

**Secrets required (for actual deploy):** DEPLOY_SSH_KEY (preferred), OR DEPLOY_PASSWORD (less secure, not recommended)
**Secrets recommended / env:** DEPLOY_HOST (default motief.sgeboers.nl), DEPLOY_USER=webapps, DEPLOY_SSH_PORT=22, DEPLOY_PATH=/home/webapps/motief (example)
**Tests to add:** none (workflow file)
**Verify:**
- Run dry-run via workflow_dispatch on Actions to verify connectivity (without running deploy). Ensure secrets are set in repository settings.
**Effort:** 2.0h

### Task 2.3: docs — deployment and Drone note
**File:** `docs/deployment/ansible-package-deploy.md`
**Summary:** Documentation describing: default host motief.sgeboers.nl, recommended DEPLOY_USER webapps, how to add GitHub repo secrets (DEPLOY_SSH_KEY, DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_PORT), sample systemd unit name recommendation (e.g., service name `motief`), and instructions for users preferring Drone: how to set equivalent secrets in Drone and a note that .drone.yml is left untouched.
**Tests to add:** none
**Verify:** open file or grep keywords DEPLOY_SSH_KEY, motief.sgeboers.nl
**Effort:** 0.75h

### Task 2.4: docs — embeddings and environment variables
**File:** `docs/embeddings.md`
**Summary:** Document that the project uses QWEN embeddings (qwen/qwen3-embedding-4b via OpenRouter), recommend setting OPENROUTER_API_KEY in repo/host secrets, and mention OPENAI_API_KEY as optional fallback. Include example env var usage and security guidance.
**Tests to add:** none
**Verify:** open file and confirm environment variables documented
**Effort:** 0.5h

---

## CI workflow outlines (YAML-level steps in prose)

Publish workflow (.github/workflows/publish-ansible-example.yml):
- on:
  - push: tags: ['v*']
  - workflow_dispatch
- jobs:
  - verify:
    - runs-on: ubuntu-latest
    - steps:
      1. actions/checkout@v4
      2. actions/setup-node@v4 (node-version: '18') with cache: 'npm'
      3. echo current package info (for debugging) but do NOT print secrets
      4. cd packages/@ansible/example && npm ci --no-audit --prefer-offline || true
      5. cd packages/@ansible/example && npm test (calls tests/run.js)
      6. cd packages/@ansible/example && npm run pack-inspect
    - artifacts: upload pack artifact for inspection (optional)

  - publish:
    - needs: verify
    - runs-on: ubuntu-latest
    - if: (github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')) && secrets.NPM_TOKEN != ''
    - steps:
      1. checkout
      2. setup-node
      3. create ephemeral ~/.npmrc with content `//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}` using `run: printf` and set 0600
      4. cd packages/@ansible/example && npm publish --access public
      5. securely remove ~/.npmrc (overwrite if shred available then rm)
    - secrets required: NPM_TOKEN
    - guard rails: job will not run if NPM_TOKEN is empty; Actions expressions used to gate execution

Deploy workflow (.github/workflows/deploy-to-vps.yml):
- on:
  - push: branches: [ main ]
  - workflow_dispatch
- inputs (for manual dispatch): target_branch, confirm_deploy (boolean)
- jobs:
  - dry-run-connect:
    - runs-on: ubuntu-latest
    - steps:
      1. checkout
      2. set env from secrets (DEPLOY_HOST=motief.sgeboers.nl default)
      3. if secrets.DEPLOY_SSH_KEY set: write ephemeral key file with 0600 and run ssh -o BatchMode=yes -p $DEPLOY_SSH_PORT $DEPLOY_USER@$DEPLOY_HOST 'echo connected' (short timeout)
      4. if no key present but DEPLOY_PASSWORD present: skip and print instructions to set key
    - outcome: outputs.has_creds true/false for next job

  - deploy:
    - needs: dry-run-connect
    - if: needs.dry-run-connect.outputs.has_creds == 'true' && github.event.inputs.confirm_deploy == 'true'
    - steps:
      1. checkout
      2. build artifacts (if required)
      3. create ephemeral key file and copy artifacts via rsync/scp to $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH
      4. optionally run remote non-destructive checks (e.g., tail logs, systemctl --user status motief)
    - secrets required for actual deploy: DEPLOY_SSH_KEY or DEPLOY_PASSWORD

Security considerations for workflows:
- Never echo secrets. Use Actions' built-in mask and avoid printing environment variables that contain secrets.
- Create ephemeral ~/.npmrc and remove it immediately. Use file permissions 0600.
- Create ephemeral SSH key files with 0600 and remove them at the end of the job.

---

## Rollback / Undo plan for risky changes

- Publish workflow added: to rollback, remove or disable `.github/workflows/publish-ansible-example.yml` and push a commit. If a package was accidentally published, use npm unpublish only if within npm policy window (careful: unpublish can harm consumers) — prefer deprecating the published version via `npm deprecate @ansible/example@x.y.z "do not use"`.
- Deploy workflow added: to rollback, remove/disable `.github/workflows/deploy-to-vps.yml`. If deployment ran, have a documented process on the VPS to rollback the app (e.g., systemd service `motief` reversion, keep previous release tarball and symlink rollback). Document in deploy docs how to revert symlink/munpack and restart systemd.
- Docs changed: simply revert doc files and commit.

Notes on npm unpublish: prefer `npm deprecate` over unpublish in most cases. Unpublish should be used only with caution and awareness of npm registry policy.

---

## Final operator checklist (manual steps before running publish/deploy)

1. Add repository secrets in GitHub Settings > Secrets:
   - NPM_TOKEN — a token with publish access to the @ansible scope (required for publish job)
   - DEPLOY_SSH_KEY — (recommended) private SSH key for `webapps` user on motief.sgeboers.nl; set as secret (do NOT include passphrase unless CI knows how to handle it)
   - Optionally DEPLOY_PASSWORD — password fallback (not recommended)
   - DEPLOY_HOST — default: motief.sgeboers.nl (recommended to set explicitly)
   - DEPLOY_USER — default: webapps
   - DEPLOY_SSH_PORT — default: 22
   - OPENROUTER_API_KEY — recommended for embeddings
   - OPENAI_API_KEY — optional fallback

2. Ensure target VPS `motief.sgeboers.nl` has user `webapps` configured and an authorized public key corresponding to the DEPLOY_SSH_KEY private key.

3. Ensure the VPS has a systemd unit name prepared (recommendation: `motief.service`) and that deployment user `webapps` may write to the deploy path (e.g., /home/webapps/motief) and manage its own files. Documented in `docs/deployment/ansible-package-deploy.md`.

4. (Optional) If you prefer Drone CI: set Drone secrets for NPM_TOKEN and DEPLOY_* equivalently; .drone.yml remains untouched and you can run publish steps in Drone if you adapt the template.

5. Verify local tests: from repo root run:
   - cd packages/@ansible/example && npm test
   - cd packages/@ansible/example && npm pack && tar -tzf <produced-tgz> | head -n 20

6. For publish: create a tag `git tag v0.1.0` (or desired semver) and push the tag. The publish workflow will run and attempt publish if NPM_TOKEN secret is present.

7. For deploy: run the deploy workflow_dispatch after setting DEPLOY_SSH_KEY and testing dry-run connectivity via the dry-run job.

---

## Decisions & Assumptions

- Package name chosen: `@ansible/example` per design doc. Changeable later by editing package.json and tag.
- Default version set to `0.1.0` to indicate initial publish candidate.
- Testing uses a zero-dependency Node test harness (simple node scripts) to avoid introducing a test framework and to keep CI minimal.
- SSH key auth chosen as default for deployment because it's more secure and scriptable in CI than password auth. SSH keys avoid exposing passwords in secrets logs and work with `ssh -o BatchMode=yes` checks. We recommend DEPLOY_SSH_KEY; DEPLOY_PASSWORD is supported as a fallback but not recommended.
- Workflows are deliberately gated: publish runs only on tags `v*` or manual dispatch, and publish job requires NPM_TOKEN secret. Deploy will not execute destructive commands unless manual confirm is provided in workflow_dispatch inputs.
- We will not modify existing `.drone.yml` as requested; docs will describe how to set Drone secrets if the operator prefers Drone.

---

## Staged execution order

1. Batch 1 (create package files & tests): tasks 1.1 — 1.7 (all parallel if multiple implementers available)
2. Batch 2 (CI & docs): tasks 2.1 — 2.4 (parallel after Batch 1 complete)

---

## Estimates summary

- Batch 1 total: ~3.75h
- Batch 2 total: ~4.75h
- Grand total: ~8.5h (approx)

---

Write this plan to: `thoughts/shared/plans/2026-03-28-rewrite-ansible-package.md`

Done.
