diff --git a/AGENTS.md b/AGENTS.md index 5bd9506c..64ade845 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,14 +151,54 @@ GitHub Actions workflow runs: The project uses semantic-release for automated versioning based on commit messages. -## Notes for AI Agents - -- Use `npm test` for unit tests and `npm run test-e2e` for integration tests. -- All new code should include corresponding tests in the mirrored `test/` directory. -- Follow the Action Module Pattern for new functionality in `src/fn/`. -- Form validation modules should follow the Form Validation Pattern in `src/lib/validation/form/`. -- PRs must pass CI (lint + tests) before merging. -- All commits should follow the Conventional Commits format. +## Non-obvious gotchas when contributing to cht-conf + +### `getFormDir` returns `null` silently — not an error +`src/lib/forms-utils.js` `getFormDir()` returns `null` when the directory is absent. +Callers that don't check the return value will silently do nothing. This is intentional: +a missing `forms/app/` in the user's project is not an error in cht-conf itself. +When writing new actions that iterate over forms, always guard against a `null` result. + +### Declarative schema validation uses conditional Joi rules — test every branch +`src/lib/compilation/validate-declarative-schema.js` uses Joi conditionals that look +like the field is always optional but are actually required or forbidden depending on +sibling field values. Key pairs: +- `event.days` vs `event.dueDate` — mutually exclusive; neither both nor neither is valid. +- `resolvedIf` — required only when every action has `type: 'contact'`, optional otherwise. +- `passesIf` — required for `type: 'percent'` targets without `groupBy`; forbidden if `groupBy` is set. +- multi-event tasks — `event.id` optional for single-event tasks, required + unique for 2+. + +When extending the schema, always add a test that confirms both the "forbidden" and +"required" branches, not just the happy path. + +### `rules.nools.js` and `tasks.js`/`targets.js` are mutually exclusive +`src/lib/compilation/compile-tasks-and-targets.js` fails at compile time if both +the legacy (`rules.nools.js`) and declarative (`tasks.js` + `targets.js`) files exist. +Both declarative files must be present together; either alone is also an error. +Test fixtures that mix styles will break this validation by design. + +### `appliesToType` semantics differ by `appliesTo` value — no compile check +In `src/nools/task-emitter.js`, `appliesToType` is matched against `report.form` +when `appliesTo: 'reports'` or `'scheduled_tasks'`, but against the resolved contact type +when `appliesTo: 'contacts'`. Contact type itself is resolved as `contact.contact_type` +when `contact.type === 'contact'`, otherwise `contact.type`. There is no schema validation +for this; wrong `appliesToType` values produce silent task non-emission in tests and prod. + +### `appliesIf` arity changes with `appliesTo: 'scheduled_tasks'` +`src/nools/task-emitter.js` passes a third `scheduledTaskIndex` argument to `appliesIf` +only when `appliesTo: 'scheduled_tasks'`. Copying task fixtures between `appliesTo` types +without adjusting the function signature will silently break the condition logic. + +### `.properties.json` unknown fields are warned, not errored +`src/lib/upload-forms.js` accepts only a fixed set of properties. Unknown fields log a +warning and are discarded — they never surface as failures. If a test asserts that a +certain property was applied, verify it is in the allowed set before trusting the test +was actually exercising the right code path. + +### `internalId` in `.properties.json` is deprecated and errors on mismatch +If a `.properties.json` contains `internalId`, it must exactly match the filename-derived +ID or upload throws. New code and fixtures should never write `internalId`; existing +fixtures that carry it are testing the deprecation warning path, not a live feature. ## Security Considerations diff --git a/README.md b/README.md index 8aa89c3a..d1f5d48b 100644 --- a/README.md +++ b/README.md @@ -15,3 +15,32 @@ Copyright 2013-2026 Medic Mobile, Inc. ## License The software is provided under AGPL-3.0. Contributions to this project are accepted under the same license. + +## AI Agent Support + +\`cht-conf\` includes features to make it easier for AI coding agents (like Claude, GitHub Copilot, or specialized CHT agents) to work on your project. + +### Initializing Agent Support +For existing projects, run: +\`\`\`bash +cht agents-md +\`\`\` +This generates \`AGENTS.md\` and \`CLAUDE.md\` files in your project root, which provide instructions and context for AI tools. + +### Local Documentation +\`cht-conf\` bundles key documentation locally so agents have access to version-matched guides without needing internet access. You can also manually refresh this documentation: +\`\`\`bash +cht update-docs +\`\`\` + +### Inspection Commands +Agents can use \`inspect\` commands to verify the state of a deployed CHT instance: +- \`cht inspect-forms\`: List all forms on the instance. +- \`cht inspect-form \`: Show details of a specific form. +- \`cht inspect-settings-diff\`: Compare local settings with the instance. +- \`cht inspect-tasks\`: List all deployed task definitions. +- \`cht inspect-targets\`: List all deployed target definitions. +- \`cht inspect-hierarchy\`: View the contact hierarchy tree. +- \`cht inspect-transitions\`: List transitions and deprecation warnings. + +All \`inspect\` commands support a \`--json\` flag for machine-parseable output. diff --git a/src/docs/cht-conf/actions.md b/src/docs/cht-conf/actions.md new file mode 100644 index 00000000..a4ff1ec7 --- /dev/null +++ b/src/docs/cht-conf/actions.md @@ -0,0 +1,76 @@ +# cht-conf Action Reference + +This document provides a summary of all supported actions in `cht-conf`. These actions can be executed by passing them as arguments to the `cht` command. + +## Backup +- `backup-all-forms`: Downloads all forms from the CHT instance and saves them to the local project directory. +- `backup-app-settings`: Fetches the current application settings from the CHT instance and saves them to a local JSON file. + +## Convert +- `convert-app-forms`: Processes local application XLSX forms into XML and Enketo-compatible formats. +- `convert-collect-forms`: Transforms local collect XLSX forms into the XML format required by the CHT. +- `convert-contact-forms`: Converts local contact XLSX forms into XML with support for place-type templating and model adjustments. +- `convert-training-forms`: Processes local training XLSX forms into XML and Enketo-compatible formats. +- `csv-to-docs`: Parses CSV files to generate JSON document files for contacts, places, and reports in the local project. + +## Upload +- `upload-app-forms`: Synchronizes compiled local application forms with the CHT instance. +- `upload-app-settings`: Uploads the compiled application settings JSON to the CHT instance. +- `upload-branding`: Uploads branding configuration and associated image assets to the CHT instance. +- `upload-collect-forms`: Synchronizes compiled local collect forms with the CHT instance. +- `upload-contact-forms`: Synchronizes compiled local contact forms with the CHT instance. +- `upload-custom-translations`: Validates and uploads custom translation properties files to the CHT instance. +- `upload-database-indexes`: Configures and uploads PouchDB/CouchDB index definitions to the CHT instance to improve query performance. +- `upload-docs`: Efficiently uploads batches of local JSON documents to the CHT instance, including handling user account updates for deleted places. +- `upload-extension-libs`: Uploads custom JavaScript libraries to the CHT instance for extending application logic. +- `upload-partners`: Uploads partner-specific configuration documents and assets to the CHT instance. +- `upload-privacy-policies`: Uploads privacy policy HTML files and their respective language mappings to the CHT instance. +- `upload-resources`: Uploads resource files and their configuration to the CHT instance. +- `upload-sms-from-csv`: Parses a CSV file and uploads the contained SMS messages to the CHT instance. +- `upload-training-forms`: Synchronizes compiled local training forms with the CHT instance. + +## Validate +- `validate-app-forms`: Checks the structure and content of local application forms for errors before conversion or upload. +- `validate-collect-forms`: Verifies the integrity of local collect forms against CHT requirements. +- `validate-contact-forms`: Validates local contact forms to ensure they follow the correct schema and conventions. +- `validate-training-forms`: Ensures local training forms are correctly structured and free of errors. + +## Delete +- `delete-all-forms`: Removes every form currently stored on the CHT instance. +- `delete-forms`: Removes specified forms from the CHT instance based on the provided names. +- `delete-contacts`: Permanently deletes specified contacts and all their descendants from the CHT instance. + +## Contacts +- `edit-contacts`: Updates existing contacts on the CHT instance using data provided in CSV files. +- `merge-contacts`: Consolidates multiple source contacts into a destination contact while moving their descendant data. +- `move-contacts`: Relocates specified contacts to a different parent location within the CHT hierarchy. + +## Compression +- `compress-images`: Orchestrates the compression of all PNG and SVG assets within the project. +- `compress-pngs`: Optimizes PNG images using external compression tools to reduce file size. +- `compress-svgs`: Minimizes SVG files using SVGO to optimize them for use in the CHT. + +## Project Management +- `check-for-updates`: Compares the local version of `cht-conf` with the latest available version on npm. +- `check-git`: Ensures the local repository is clean and synchronized with its upstream branch before critical operations. +- `compile-app-settings`: Aggregates modular configuration files, scripts, and rules into a single application settings file. +- `create-users`: Generates or updates user accounts on the CHT instance from a provided CSV file. +- `fetch-csvs-from-google-drive`: Downloads CSV data from specified Google Drive files into the local project. +- `fetch-forms-from-google-drive`: Retrieves XLSX forms from Google Drive and saves them locally for processing. +- `initialise-project-layout`: Sets up a new project with the standard CHT configuration directory structure and boilerplate files. +- `watch-project`: Automatically triggers validation, compilation, and upload actions whenever local project files are modified. + +## AI Agent Support +- `agents-md`: Generates `AGENTS.md` and `CLAUDE.md` files in the project root to guide AI coding agents. +- `update-docs`: Fetches the latest documentation from the `medic/cht-docs` repository and stores it locally for AI agents. + +## Inspection +- `inspect-errors`: Fetches and displays recent sentinel or API errors from the `medic-logs` database. +- `inspect-form`: Shows detailed information, fields, and calculations for a specific deployed form (requires `` argument). +- `inspect-forms`: Lists all forms currently deployed on the CHT instance. +- `inspect-hierarchy`: Displays a simplified tree view of the top-level contact hierarchy. +- `inspect-replication`: Shows the status and progress of active CouchDB replication tasks. +- `inspect-settings-diff`: Compares local application settings (`app_settings.json`) with the settings currently deployed on the CHT instance. +- `inspect-targets`: Lists all target definitions deployed in the application settings. +- `inspect-tasks`: Lists all task definitions deployed in the application settings. +- `inspect-transitions`: Displays all configured transitions and their statuses, including any deprecation warnings. diff --git a/src/docs/cht-conf/form-conventions.md b/src/docs/cht-conf/form-conventions.md new file mode 100644 index 00000000..66b72940 --- /dev/null +++ b/src/docs/cht-conf/form-conventions.md @@ -0,0 +1,37 @@ +# CHT Form Conventions + +CHT forms use XLSForm (Excel) files. However, there are several CHT-specific conventions that AI agents must follow to ensure forms work correctly with `cht-conf` and the CHT core. + +## 1. db-doc References +CHT supports referencing documents (like people or places) directly in forms. +- `db-doc:`: In the `type` column, use `db-doc:person` or `db-doc:place` to create a selection field for contacts. + +## 2. xml-external Itemsets +For large lists (like a list of all villages), CHT uses external XML files. +- Use the `xml-external` pattern in the `type` column. + +## 3. NO_LABEL Placeholder +(Deprecated, but still used in older forms) +- If a field should not have a label, some forms use `NO_LABEL`. +- **Better approach**: For groups, labels are not required. For hidden fields, use `hidden` or `calculate` types. + +## 4. Contact Summary Data +Forms can access data from the contact summary of the subject. +- Use `instance('contact-summary')/contact/some_field` in calculations. + +## 5. Metadata and Inputs +All CHT forms automatically get a `meta` section and an `inputs` section during conversion by `cht-conf`. +- Do not manually define `inputs/meta/location` in the XLSForm unless specifically required; `cht-conf` will inject it. + +## 6. Form IDs and Names +- **Contact Forms**: Must end in `-create` or `-edit` (e.g., `person-create`, `clinic-edit`). +- **File Names**: The `.xlsx` filename determines the internal form ID. Avoid spaces and special characters. + +## 7. Media Files +- Images, audio, and video for forms should be placed in a directory named `-media/` next to the `.xlsx` file. +- Example: `forms/app/assessment-media/image.png` for `forms/app/assessment.xlsx`. + +## 8. Hidden Fields +To hide a field from the user but keep it in the submitted document: +- Use the `hidden` appearance in the XLSForm. +- OR list the field in the `hidden_fields` array in the companion `.properties.json` file. diff --git a/src/docs/cht-conf/project-structure.md b/src/docs/cht-conf/project-structure.md new file mode 100644 index 00000000..5c74fed2 --- /dev/null +++ b/src/docs/cht-conf/project-structure.md @@ -0,0 +1,44 @@ +# CHT Project Directory Structure + +A standard CHT configuration project managed by `cht-conf` follows this directory structure. AI agents must strictly adhere to these locations when creating or modifying files. + +## Root Directory Files + +- `app_settings.json`: (Optional) The complete application settings. Often split into the `app_settings/` directory. +- `contact-summary.templated.js`: Defines the fields and cards shown in the contact summary. +- `targets.js`: Defines the analytics targets shown in the app. +- `tasks.js`: Defines the tasks shown to users. +- `rules.nools`: (Optional) Custom Nools rules for tasks and targets (older projects). +- `privacy-policies.json`: Configuration for privacy policies. +- `resources.json`: Configuration for branding and resources. + +## Directories + +### `forms/` +Contains all XLSForm files and their companion properties. **Crucial: Do not mix form types.** + +- `forms/app/`: User-facing forms (e.g., assessments, follow-ups). +- `forms/contact/`: Forms for creating/editing places and people (e.g., `person-create.xlsx`, `clinic-edit.xlsx`). +- `forms/collect/`: (Legacy) Forms for ODK Collect. +- `forms/training/`: Forms used in training mode. + +### `app_settings/` +Instead of one giant `app_settings.json`, settings can be split here: +- `app_settings/base_settings.json`: General settings. +- `app_settings/forms.json`: Form-specific settings. +- `app_settings/schedules.json`: Task schedule definitions. + +### `resources/` +Contains images and other assets for branding (e.g., `logo.png`). + +### `translations/` +Contains `.properties` files for multilingual support (e.g., `messages-en.properties`). + +### `test/` +Contains test files for forms, tasks, and targets. + +## Form Companion Files +Every `.xlsx` form may have a companion `.properties.json` file with the same base name. +Example: `forms/app/assessment.xlsx` -> `forms/app/assessment.properties.json` +- `context`: (Optional) Restricts form visibility to specific contact types or conditions. +- `hidden_fields`: (Optional) List of fields to be hidden from the UI but kept in the doc. diff --git a/src/fn/agents-md.js b/src/fn/agents-md.js new file mode 100644 index 00000000..39b9c100 --- /dev/null +++ b/src/fn/agents-md.js @@ -0,0 +1,29 @@ +const path = require('node:path'); +const environment = require('../lib/environment'); +const fs = require('../lib/sync-fs'); +const { info, warn } = require('../lib/log'); +const { AGENTS_MD, CLAUDE_MD } = require('../lib/agent-templates'); + +const execute = async () => { + const projectDir = environment.pathToProject; + + const files = [ + { name: 'AGENTS.md', content: AGENTS_MD }, + { name: 'CLAUDE.md', content: CLAUDE_MD } + ]; + + for (const file of files) { + const filePath = path.join(projectDir, file.name); + if (fs.exists(filePath)) { + warn(`${file.name} already exists at ${filePath}. Skipping.`); + } else { + info(`Creating ${file.name} at ${filePath}`); + fs.write(filePath, file.content); + } + } +}; + +module.exports = { + requiresInstance: false, + execute +}; diff --git a/src/fn/initialise-project-layout.js b/src/fn/initialise-project-layout.js index c6ee3133..99246226 100644 --- a/src/fn/initialise-project-layout.js +++ b/src/fn/initialise-project-layout.js @@ -3,8 +3,11 @@ const path = require('path'); const environment = require('../lib/environment'); const fs = require('../lib/sync-fs'); const { info } = require('../lib/log'); +const { AGENTS_MD, CLAUDE_MD } = require('../lib/agent-templates'); const LAYOUT = { + 'AGENTS.md': AGENTS_MD, + 'CLAUDE.md': CLAUDE_MD, 'contact-summary.templated.js': `module.exports = { fields: [], cards: [], diff --git a/src/fn/inspect-errors.js b/src/fn/inspect-errors.js new file mode 100644 index 00000000..1ad331c4 --- /dev/null +++ b/src/fn/inspect-errors.js @@ -0,0 +1,50 @@ +/* eslint-disable no-console */ +const getApi = require('../lib/api'); +const { info, error } = require('../lib/log'); +const environment = require('../lib/environment'); +const url = require('node:url'); + +const execute = async () => { + const api = getApi(); + + try { + const parsedUrl = new url.URL(environment.apiUrl); + const baseUrl = `${parsedUrl.protocol}//${parsedUrl.username}:${parsedUrl.password}@${parsedUrl.host}`; + + // Fetch recent documents from medic-logs + const limit = 20; + const logsUrl = `${baseUrl}/medic-logs/_all_docs?include_docs=true&descending=true&limit=${limit}`; + const result = await api.get({ url: logsUrl, json: true }); + + const errors = result.rows + .map(row => row.doc) + .filter(doc => doc && !doc._id.startsWith('_design/')); + + if (environment.extraArgs.includes('--json')) { + console.log(JSON.stringify(errors, null, 2)); + return; + } + + info(`Recent ${errors.length} log/error entries from medic-logs:`); + errors.forEach((errDoc, i) => { + console.log(`${i + 1}. [${errDoc.timestamp || errDoc.created || errDoc._id}] ${errDoc.type || 'unknown type'}`); + if (errDoc.error || errDoc.message) { + console.log(` Message: ${errDoc.error || errDoc.message}`); + } + if (errDoc.action) { + console.log(` Action: ${errDoc.action}`); + } + }); + } catch (err) { + if (err.statusCode === 404) { + error('Could not access medic-logs database. Ensure you have the right permissions or the database exists.'); + } else { + error(`Failed to fetch errors: ${err.message}`); + } + } +}; + +module.exports = { + requiresInstance: true, + execute +}; diff --git a/src/fn/inspect-form.js b/src/fn/inspect-form.js new file mode 100644 index 00000000..5c95a45e --- /dev/null +++ b/src/fn/inspect-form.js @@ -0,0 +1,34 @@ +/* eslint-disable no-console */ +const getApi = require('../lib/api'); +const { info, error } = require('../lib/log'); +const environment = require('../lib/environment'); + +const execute = async () => { + const formId = environment.extraArgs.find(arg => !arg.startsWith('-')); + if (!formId) { + error('Please specify a form ID: cht inspect-form '); + return; + } + + const api = getApi(); + const settings = await api.getAppSettings(); + + const form = settings?.forms?.[formId]; + if (!form) { + error(`Form ${formId} not found in deployed settings.`); + return; + } + + if (environment.extraArgs.includes('--json')) { + console.log(JSON.stringify(form, null, 2)); + return; + } + + info(`Form details for ${formId}:`); + console.log(JSON.stringify(form, null, 2)); +}; + +module.exports = { + requiresInstance: true, + execute +}; diff --git a/src/fn/inspect-forms.js b/src/fn/inspect-forms.js new file mode 100644 index 00000000..943e666f --- /dev/null +++ b/src/fn/inspect-forms.js @@ -0,0 +1,25 @@ +/* eslint-disable no-console */ +const getApi = require('../lib/api'); +const { info } = require('../lib/log'); +const environment = require('../lib/environment'); + +const execute = async () => { + const api = getApi(); + const settings = await api.getAppSettings(); + + const forms = settings.forms || {}; + const formIds = Object.keys(forms); + + if (environment.extraArgs.includes('--json')) { + console.log(JSON.stringify(formIds, null, 2)); + return; + } + + info(`Found ${formIds.length} deployed forms:`); + formIds.forEach(id => console.log(`- ${id}`)); +}; + +module.exports = { + requiresInstance: true, + execute +}; diff --git a/src/fn/inspect-hierarchy.js b/src/fn/inspect-hierarchy.js new file mode 100644 index 00000000..949979d4 --- /dev/null +++ b/src/fn/inspect-hierarchy.js @@ -0,0 +1,45 @@ +/* eslint-disable no-console */ +const pouch = require('../lib/db'); +const { info } = require('../lib/log'); +const environment = require('../lib/environment'); + +const execute = async () => { + const db = pouch(); + + // Fetch all places (depth 0 and 1) + const result = await db.query('medic/contacts_by_depth', { + include_docs: true, + limit: 100, + }); + + const contacts = result.rows.map(row => row.doc); + + if (environment.extraArgs.includes('--json')) { + console.log(JSON.stringify(contacts, null, 2)); + return; + } + + info('Contact Hierarchy (Top 100):'); + + const tree = {}; + contacts.forEach(c => { + const parentId = c.parent ? (c.parent._id || c.parent) : 'root'; + if (!tree[parentId]) { tree[parentId] = []; } + tree[parentId].push(c); + }); + + function printNode(id, indent = '') { + const children = tree[id] || []; + children.forEach(child => { + console.log(`${indent}- ${child.name || child._id} (${child.type}) [${child._id}]`); + printNode(child._id, indent + ' '); + }); + } + + printNode('root'); +}; + +module.exports = { + requiresInstance: true, + execute +}; diff --git a/src/fn/inspect-replication.js b/src/fn/inspect-replication.js new file mode 100644 index 00000000..1ce40349 --- /dev/null +++ b/src/fn/inspect-replication.js @@ -0,0 +1,39 @@ +/* eslint-disable no-console */ +const getApi = require('../lib/api'); +const { info, error } = require('../lib/log'); +const environment = require('../lib/environment'); +const url = require('node:url'); + +const execute = async () => { + const api = getApi(); + + try { + const parsedUrl = new url.URL(environment.apiUrl); + const baseUrl = `${parsedUrl.protocol}//${parsedUrl.username}:${parsedUrl.password}@${parsedUrl.host}`; + + // CouchDB active tasks + const activeTasks = await api.get({ url: `${baseUrl}/_active_tasks`, json: true }); + + const replications = activeTasks.filter(task => task.type === 'replication'); + + if (environment.extraArgs.includes('--json')) { + console.log(JSON.stringify(replications, null, 2)); + return; + } + + info(`Found ${replications.length} active replication tasks:`); + replications.forEach(task => { + console.log(`- Task: ${task.task || task.replication_id}`); + console.log(` Source: ${task.source}`); + console.log(` Target: ${task.target}`); + console.log(` Status: ${task.docs_written} docs written, ${task.docs_read} read. Prog: ${task.progress}%`); + }); + } catch (err) { + error(`Failed to fetch replication status: ${err.message}`); + } +}; + +module.exports = { + requiresInstance: true, + execute +}; diff --git a/src/fn/inspect-settings-diff.js b/src/fn/inspect-settings-diff.js new file mode 100644 index 00000000..c156d0a9 --- /dev/null +++ b/src/fn/inspect-settings-diff.js @@ -0,0 +1,47 @@ +/* eslint-disable no-console */ +const getApi = require('../lib/api'); +const { info } = require('../lib/log'); +const environment = require('../lib/environment'); +const jsonDiff = require('json-diff'); +const fs = require('../lib/sync-fs'); +const path = require('node:path'); + +const execute = async () => { + const api = getApi(); + const remoteSettings = await api.getAppSettings(); + + // Try to find local settings + let localSettings; + const projectDir = environment.pathToProject; + + // Try app_settings.json first + const appSettingsPath = path.join(projectDir, 'app_settings.json'); + if (fs.exists(appSettingsPath)) { + localSettings = fs.readJson(appSettingsPath); + } else { + // Try compiled settings (assuming they might have been compiled already) + // Or we should trigger a compilation here? + // For now, let's just look for app_settings/ directory and maybe warn. + info('app_settings.json not found. To compare modular settings, please compile them first.'); + return; + } + + const diff = jsonDiff.diffString(remoteSettings, localSettings); + + if (environment.extraArgs.includes('--json')) { + console.log(JSON.stringify(jsonDiff.diff(remoteSettings, localSettings), null, 2)); + return; + } + + if (diff) { + info('Differences found between local and remote settings:'); + console.log(diff); + } else { + info('Local and remote settings are in sync.'); + } +}; + +module.exports = { + requiresInstance: true, + execute +}; diff --git a/src/fn/inspect-targets.js b/src/fn/inspect-targets.js new file mode 100644 index 00000000..38050ad6 --- /dev/null +++ b/src/fn/inspect-targets.js @@ -0,0 +1,26 @@ +/* eslint-disable no-console */ +const getApi = require('../lib/api'); +const { info } = require('../lib/log'); +const environment = require('../lib/environment'); + +const execute = async () => { + const api = getApi(); + const settings = await api.getAppSettings(); + + const targets = settings.targets || []; + + if (environment.extraArgs.includes('--json')) { + console.log(JSON.stringify(targets, null, 2)); + return; + } + + info(`Found ${targets.length} deployed targets:`); + targets.forEach((target, index) => { + console.log(`${index + 1}. ${target.id || 'Unnamed target'} (${target.type || 'no type'})`); + }); +}; + +module.exports = { + requiresInstance: true, + execute +}; diff --git a/src/fn/inspect-tasks.js b/src/fn/inspect-tasks.js new file mode 100644 index 00000000..7200a425 --- /dev/null +++ b/src/fn/inspect-tasks.js @@ -0,0 +1,26 @@ +/* eslint-disable no-console */ +const getApi = require('../lib/api'); +const { info } = require('../lib/log'); +const environment = require('../lib/environment'); + +const execute = async () => { + const api = getApi(); + const settings = await api.getAppSettings(); + + const tasks = settings.tasks || []; + + if (environment.extraArgs.includes('--json')) { + console.log(JSON.stringify(tasks, null, 2)); + return; + } + + info(`Found ${tasks.length} deployed tasks:`); + tasks.forEach((task, index) => { + console.log(`${index + 1}. ${task.name || 'Unnamed task'} (${task.icon || 'no icon'})`); + }); +}; + +module.exports = { + requiresInstance: true, + execute +}; diff --git a/src/fn/inspect-transitions.js b/src/fn/inspect-transitions.js new file mode 100644 index 00000000..462d5d12 --- /dev/null +++ b/src/fn/inspect-transitions.js @@ -0,0 +1,55 @@ +/* eslint-disable no-console */ +const getApi = require('../lib/api'); +const { info, warn } = require('../lib/log'); +const environment = require('../lib/environment'); + +const execute = async () => { + const api = getApi(); + const settings = await api.getAppSettings(); + + const transitions = settings.transitions || {}; + const transitionNames = Object.keys(transitions); + + if (environment.extraArgs.includes('--json')) { + console.log(JSON.stringify(transitions, null, 2)); + return; + } + + info(`Found ${transitionNames.length} transitions:`); + + // Fetch deprecations if possible + try { + const uri = `${environment.instanceUrl}/api/v1/settings/deprecated-transitions`; + const deprecatedTransitions = await api.get({ uri, json: true }); // Wait, api() returns the object + // Actually api() in api.js returns different things based on isArchiveMode + // But getApi() I used in other files is just require('../lib/api') + + transitionNames.forEach(name => { + const config = transitions[name]; + const status = config.disable ? 'Disabled' : 'Enabled'; + const deprecated = (deprecatedTransitions || []).find(d => d.name === name); + + let msg = `- ${name} [${status}]`; + if (deprecated && !config.disable) { + msg += ' (DEPRECATED)'; + console.log(msg); + warn(` ${deprecated.deprecationMessage}`); + } else { + console.log(msg); + } + }); + } catch (err) { + warn(`Failed to fetch deprecated transitions status: ${err.message}`); + // If deprecated-transitions endpoint fails, just list them + transitionNames.forEach(name => { + const config = transitions[name]; + const status = config.disable ? 'Disabled' : 'Enabled'; + console.log(`- ${name} [${status}]`); + }); + } +}; + +module.exports = { + requiresInstance: true, + execute +}; diff --git a/src/fn/update-docs.js b/src/fn/update-docs.js new file mode 100644 index 00000000..6f3cf55e --- /dev/null +++ b/src/fn/update-docs.js @@ -0,0 +1,45 @@ +const path = require('node:path'); +const os = require('node:os'); +const fs = require('../lib/sync-fs'); +const { info, error } = require('../lib/log'); +const getApi = require('../lib/api'); + +const DOCS_DIR = path.join(os.homedir(), '.cht-conf', 'docs'); +const REPO_URL = 'https://raw.githubusercontent.com/medic/cht-docs/master/content/en/core/6.x/configuring'; + +const FILES_TO_FETCH = [ + 'forms.md', + 'tasks.js.md', + 'targets.js.md', + 'contact-summary.js.md', +]; + +const execute = async () => { + const api = getApi(); + + if (!fs.exists(DOCS_DIR)) { + fs.mkdir(DOCS_DIR); + } + + info(`Fetching fresh documentation to ${DOCS_DIR}...`); + + for (const file of FILES_TO_FETCH) { + const url = `${REPO_URL}/${file}`; + try { + // Use the generic get we just added to api.js + const content = await api.get({ uri: url }); + const localPath = path.join(DOCS_DIR, file); + fs.write(localPath, content); + info(`- Fetched ${file}`); + } catch (err) { + error(`- Failed to fetch ${file}: ${err.message}`); + } + } + + info('Documentation update complete.'); +}; + +module.exports = { + requiresInstance: false, + execute +}; diff --git a/src/lib/agent-templates.js b/src/lib/agent-templates.js new file mode 100644 index 00000000..5e3d30dc --- /dev/null +++ b/src/lib/agent-templates.js @@ -0,0 +1,110 @@ +module.exports = { + AGENTS_MD: `# CHT Project AI Agent Guide + +This project is a CHT (Community Health Toolkit) configuration project. + +## Silent failures — things that compile but do nothing + +### Wrong form directory → upload silently skips +Forms must live in exactly \`forms/app/\`, \`forms/contact/\`, \`forms/training/\`, or \`forms/collect/\`. +If the directory doesn't exist or is misspelled (e.g. \`forms/app-forms/\`, \`forms/contacts/\`), +\`cht upload-app-forms\` runs without error but uploads nothing. +Always verify the directory name exactly before creating forms. + +### Contact forms must be named \`*-create.xlsx\` or \`*-edit.xlsx\` +A file like \`forms/contact/person.xlsx\` converts without error but will fail on upload because +the form ID in the XML must match the filename pattern \`contact:person-create\` or \`contact:person-edit\`. +The suffix determines the action type; omitting it produces an ID mismatch error on upload. + +### \`.properties.json\` — only these fields are read +The following fields are the only ones with effect: \`context\`, \`icon\`, \`title\`, +\`xml2sms\`, \`subject_key\`, \`hidden_fields\`, and (contact forms only) \`duplicate_check\`. +Any other field is silently ignored. The deprecated \`internalId\` field throws an error +if it doesn't match the filename; remove it if present. + +--- + +## tasks.js / targets.js schema gotchas + +### tasks.js and targets.js must both exist at project root +These files must live at the project root, not in \`app_settings/\`. +If only one is present, \`compile-app-settings\` fails. If \`rules.nools.js\` also exists, +compilation fails because the legacy and declarative styles are mutually exclusive. + +### Multiple task events require unique \`id\` fields +A single-event task may omit the event \`id\`. As soon as a task has two or more events, +every event must have a unique \`id\` string — the schema enforces this conditionally +and the error only surfaces at compile time. + +### \`event.days\` and \`event.dueDate\` are mutually exclusive +Each task event uses either \`days: \` (relative due date) or +\`dueDate: function(event, contact, report)\` (computed due date), never both, never neither. +Including both produces a schema error; omitting both also fails validation. + +### \`resolvedIf\` is required when all actions have \`type: 'contact'\` +If every action in a task has \`type: 'contact'\`, the \`resolvedIf\` field is required. +If any action has \`type: 'report'\` (or no type), \`resolvedIf\` becomes optional. +A task that opens a contact creation form without \`resolvedIf\` will fail validation. + +### Percent targets require \`passesIf\`; \`groupBy\` forbids it +- \`type: 'percent'\` without \`groupBy\`: \`passesIf\` is required. +- Any target with \`groupBy\` defined: \`passesIf\` is forbidden. +Including \`passesIf\` alongside \`groupBy\`, or omitting it from a percent target, both fail validation. + +### Task and target names must be unique +Duplicate \`name\` values across tasks, or duplicate \`id\` values across targets, +fail schema validation at compile time with a "contains duplicate value" error. + +--- + +## Runtime behaviour that produces no compile error + +### \`appliesToType\` for reports matches the form name, not the contact type +When \`appliesTo: 'reports'\`, \`appliesToType\` is compared against \`report.form\` +(the XLSForm file name without extension), not the contact type. A task with +\`appliesTo: 'reports', appliesToType: ['person']\` won't fire on reports submitted +by people of type \`person\`; it fires on reports whose form ID is \`"person"\`. + +### Contact type resolution uses two fields +A contact's type is resolved as: if \`contact.type === 'contact'\`, use \`contact.contact_type\`; +otherwise use \`contact.type\`. A task with \`appliesToType: ['chw']\` will match contacts +where \`type='contact', contact_type='chw'\` but not where \`type='chw'\` unless it also +falls through the other branch. Keep this in mind when writing \`appliesIf\` predicates. + +### \`appliesIf\` for \`scheduled_tasks\` receives a third argument +When \`appliesTo: 'scheduled_tasks'\`, \`appliesIf(contact, report, scheduledTaskIndex)\` +receives the index of the scheduled task within the report. For all other \`appliesTo\` +values it receives only two arguments. Copying a task definition and changing \`appliesTo\` +without updating \`appliesIf\`'s signature produces silent logic errors. +`, + CLAUDE_MD: `# Claude Instructions for CHT Project + +You are an AI agent helping with a CHT (Community Health Toolkit) configuration project. + +## Critical: things that fail silently + +- **Wrong form directory**: \`cht upload-app-forms\` exits 0 and uploads nothing if \`forms/app/\` + doesn't exist. Use exactly \`forms/app/\`, \`forms/contact/\`, \`forms/training/\`, \`forms/collect/\`. +- **Contact form naming**: files in \`forms/contact/\` must end in \`-create.xlsx\` or \`-edit.xlsx\`. + Any other name converts fine but fails on upload with an ID mismatch. +- **Extra fields in \`.properties.json\`** are silently ignored. Only \`context\`, \`icon\`, \`title\`, + \`xml2sms\`, \`subject_key\`, \`hidden_fields\`, and (contact only) \`duplicate_check\` take effect. + +## tasks.js / targets.js rules + +- Both \`tasks.js\` and \`targets.js\` must exist at the project root when using declarative config. + Having one without the other, or having \`rules.nools.js\` alongside either, causes compile failure. +- Two or more task events → each event must have a unique \`id\` field. +- Each event uses either \`days: \` or \`dueDate: fn\`, never both and never neither. +- When all task actions have \`type: 'contact'\`, \`resolvedIf\` is required. +- \`type: 'percent'\` targets require \`passesIf\` (unless \`groupBy\` is set, which forbids it). +- Task \`name\` and target \`id\` values must be unique across their respective arrays. + +## Runtime gotchas (no compile error) + +- \`appliesToType\` when \`appliesTo: 'reports'\` matches the **form name** (\`report.form\`), + not the submitter's contact type. +- Contact type = \`contact.contact_type\` when \`contact.type === 'contact'\`, else \`contact.type\`. +- \`appliesIf\` for \`appliesTo: 'scheduled_tasks'\` receives a third \`scheduledTaskIndex\` argument. +` +}; diff --git a/src/lib/api.js b/src/lib/api.js index f7530fd9..ca664cf4 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -142,6 +142,10 @@ const api = { }); }, + get(options) { + return request.get(options); + }, + /** * Check whether the API is alive or not. The request * is made to a "lightweight" endpoint that only returns a HTTP 302,