Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

appwrite-ctl

Package Overview
Dependencies
Maintainers
1
Versions
4
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

appwrite-ctl - npm Package Compare versions

Comparing version
1.0.2
to
1.0.3
+26
dist/lib/security.d.ts
import type { SecurityException, SecurityExceptions, SecurityLedger, SecurityRules } from '../types/index.js';
export type { SecurityException, SecurityExceptions, SecurityLedger, SecurityRules };
/**
* Default security rules included in every freshly initialised appwrite-ctl.config.json.
* Mirrors the validation intent of the future `appwrite-ctl audit` command.
*/
export declare const DEFAULT_RULES: SecurityRules;
/**
* Load the security ledger from the `security` key inside appwrite-ctl.config.json.
* Returns an empty ledger if the key or file does not exist.
*/
export declare const loadSecurityLedger: (appwriteDir: string) => SecurityLedger;
/**
* Persist the security ledger back into the `security` key of appwrite-ctl.config.json,
* preserving all other top-level keys.
*/
export declare const saveSecurityLedger: (appwriteDir: string, ledger: SecurityLedger) => void;
/**
* Return the exceptions list for a specific resource type + ID.
* Returns an empty array if no entry exists.
*/
export declare const getExceptions: (ledger: SecurityLedger, type: "collections" | "buckets", id: string) => SecurityException[];
/**
* Resolve the current author using `git config user.name` falling back to the OS username.
*/
export declare const resolveAuthor: () => string;
import fs from 'fs';
import os from 'os';
import path from 'path';
import { execSync } from 'child_process';
const CTL_CONFIG_FILENAME = 'appwrite-ctl.config.json';
/**
* Default security rules included in every freshly initialised appwrite-ctl.config.json.
* Mirrors the validation intent of the future `appwrite-ctl audit` command.
*/
export const DEFAULT_RULES = {
'require-row-security': { enabled: true, severity: 'error' },
'forbid-role-all-write': { enabled: true, severity: 'error' },
'forbid-role-all-delete': { enabled: true, severity: 'error' },
'forbid-role-all-read': { enabled: true, severity: 'warn' },
'forbid-role-all-create': { enabled: true, severity: 'warn' },
'require-file-security': { enabled: true, severity: 'warn' },
};
/**
* Assert that a resolved file path stays within the expected parent directory.
* Throws if the path escapes via `..` components.
*/
const assertSafePath = (resolvedPath, expectedParent) => {
const normalizedParent = path.resolve(expectedParent);
const normalizedTarget = path.resolve(resolvedPath);
if (!normalizedTarget.startsWith(normalizedParent + path.sep) &&
normalizedTarget !== normalizedParent) {
throw new Error(`Path traversal detected: '${resolvedPath}' is outside '${expectedParent}'.`);
}
};
/**
* Read the raw appwrite-ctl.config.json object from disk.
* Returns an empty object if the file does not exist or cannot be parsed.
*/
const readCtlConfig = (appwriteDir) => {
const filePath = path.join(appwriteDir, CTL_CONFIG_FILENAME);
assertSafePath(filePath, process.cwd());
if (!fs.existsSync(filePath))
return {};
try {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}
catch {
return {};
}
};
/**
* Write the raw appwrite-ctl.config.json object back to disk.
*/
const writeCtlConfig = (appwriteDir, data) => {
const filePath = path.join(appwriteDir, CTL_CONFIG_FILENAME);
assertSafePath(filePath, process.cwd());
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
};
/**
* Load the security ledger from the `security` key inside appwrite-ctl.config.json.
* Returns an empty ledger if the key or file does not exist.
*/
export const loadSecurityLedger = (appwriteDir) => {
const cfg = readCtlConfig(appwriteDir);
const raw = cfg.security;
if (!raw || typeof raw !== 'object') {
return { exceptions: {} };
}
return { rules: raw.rules, exceptions: raw.exceptions ?? {} };
};
/**
* Persist the security ledger back into the `security` key of appwrite-ctl.config.json,
* preserving all other top-level keys.
*/
export const saveSecurityLedger = (appwriteDir, ledger) => {
const cfg = readCtlConfig(appwriteDir);
cfg.security = ledger;
writeCtlConfig(appwriteDir, cfg);
};
/**
* Return the exceptions list for a specific resource type + ID.
* Returns an empty array if no entry exists.
*/
export const getExceptions = (ledger, type, id) => {
return ledger.exceptions[type]?.[id] ?? [];
};
/**
* Resolve the current author using `git config user.name` falling back to the OS username.
*/
export const resolveAuthor = () => {
try {
const name = execSync('git config user.name', { encoding: 'utf-8', stdio: 'pipe' }).trim();
if (name)
return name;
}
catch {
// Not in a git repo or git not available
}
return os.userInfo().username;
};
+129
-21

@@ -12,2 +12,3 @@ #!/usr/bin/env node

import { generateSchemaDoc } from '../lib/diagram.js';
import { loadSecurityLedger, saveSecurityLedger, resolveAuthor, DEFAULT_RULES, } from '../lib/security.js';
const program = new Command();

@@ -21,5 +22,5 @@ const generateDocs = (snapshotPath, version, outputDir) => {

}
const outputPath = path.join(outputDir, 'schema.md');
const outputPath = path.join(outputDir, 'docs.md');
fs.writeFileSync(outputPath, markdown);
console.log(chalk.green(`Schema docs updated at ${outputPath}`));
console.log(chalk.green(`Docs updated at ${outputPath}`));
};

@@ -37,3 +38,3 @@ program

const migrationDir = path.join(appwriteDir, 'migration');
const configPath = path.join(migrationDir, 'config.json');
const ctlConfigPath = path.join(appwriteDir, 'appwrite-ctl.config.json');
if (!fs.existsSync(appwriteDir))

@@ -43,12 +44,16 @@ fs.mkdirSync(appwriteDir);

fs.mkdirSync(migrationDir);
if (!fs.existsSync(configPath)) {
if (!fs.existsSync(ctlConfigPath)) {
const config = {
collection: 'migrations',
database: 'system',
security: {
rules: DEFAULT_RULES,
exceptions: {},
},
};
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
console.log(chalk.green('Created appwrite/migration/config.json'));
fs.writeFileSync(ctlConfigPath, JSON.stringify(config, null, 2) + '\n');
console.log(chalk.green('Created appwrite/appwrite-ctl.config.json'));
}
else {
console.log(chalk.yellow('Config file already exists.'));
console.log(chalk.yellow('appwrite-ctl.config.json already exists — not overwritten.'));
}

@@ -98,3 +103,2 @@ console.log(chalk.green('Initialization complete.'));

description: "${name}",
requiresBackup: false,
up: async ({ client, databases, log, error }) => {

@@ -159,3 +163,3 @@ log("Executing up migration for ${name}");

generateDocs(path.join(versionPath, snapshotFilename), version, versionPath);
console.log(chalk.green(`Successfully updated schema.md for ${version}`));
console.log(chalk.green(`Successfully updated docs.md for ${version}`));
}

@@ -219,19 +223,39 @@ catch (error) {

});
migrations
program
.command('docs [version]')
.description('Pull current state from Appwrite and generate schema documentation with ER diagrams')
.action(async () => {
.description('Generate schema documentation with ER diagrams. Optionally pass a version (e.g. v1) to ' +
'generate docs from a stored snapshot instead of pulling from Appwrite.')
.action(async (version) => {
try {
const options = program.opts();
const config = loadConfig(options.env);
console.log(chalk.blue(`Pulling latest schema from Appwrite to project root...`));
await configureClient(config);
const snapshotPath = await pullSnapshot();
console.log(chalk.blue('Generating documentation...'));
const appwriteDir = path.join(process.cwd(), 'appwrite');
generateDocs(snapshotPath, 'latest', appwriteDir);
// Cleanup the temporary snapshot pulled to root
if (fs.existsSync(snapshotPath)) {
fs.unlinkSync(snapshotPath);
if (version) {
// Use stored snapshot for the given version without hitting Appwrite.
const versionPath = path.join(appwriteDir, 'migration', version);
if (!fs.existsSync(versionPath)) {
console.error(chalk.red(`Version directory '${version}' not found.`));
process.exit(1);
}
const snapshotFilename = getSnapshotFilename();
const snapshotPath = path.join(versionPath, snapshotFilename);
if (!fs.existsSync(snapshotPath)) {
console.error(chalk.red(`No snapshot found for ${version}.`));
process.exit(1);
}
console.log(chalk.blue(`Generating docs from stored snapshot for ${version}...`));
generateDocs(snapshotPath, version, appwriteDir);
generateDocs(snapshotPath, version, versionPath);
}
else {
const config = loadConfig(options.env);
console.log(chalk.blue(`Pulling latest schema from Appwrite to project root...`));
await configureClient(config);
const snapshotPath = await pullSnapshot();
console.log(chalk.blue('Generating documentation...'));
generateDocs(snapshotPath, 'latest', appwriteDir);
// Cleanup the temporary snapshot pulled to root
if (fs.existsSync(snapshotPath)) {
fs.unlinkSync(snapshotPath);
}
}
}

@@ -243,2 +267,86 @@ catch (error) {

});
const RESOURCE_TYPES = ['Collection', 'Bucket'];
const exceptions = program
.command('exceptions')
.description('Manage security exception entries in security.json');
exceptions
.command('add')
.description('Interactively add a new security exception entry to appwrite/security.json')
.action(async () => {
const { default: inquirer } = await import('inquirer');
const appwriteDir = path.join(process.cwd(), 'appwrite');
const ledger = loadSecurityLedger(appwriteDir);
const author = resolveAuthor();
const today = new Date().toISOString().split('T')[0];
console.log(chalk.blue(`Author resolved as: ${chalk.bold(author)}`));
const answers = await inquirer.prompt([
{
type: 'list',
name: 'resourceType',
message: 'Resource type:',
choices: RESOURCE_TYPES,
},
{
type: 'input',
name: 'resourceId',
message: 'Resource ID (collection/bucket ID):',
validate: (v) => v.trim().length > 0 || 'Resource ID is required.',
},
{
// Use a list picker when rules are configured, otherwise free text
type: Object.keys(ledger.rules ?? {}).length > 0 ? 'list' : 'input',
name: 'rule',
message: 'Rule being bypassed:',
choices: Object.keys(ledger.rules ?? {}),
validate: (v) => v.trim().length > 0 || 'Rule is required.',
},
{
type: 'input',
name: 'justification',
message: 'Technical justification:',
validate: (v) => v.trim().length > 0 || 'Justification is required.',
},
]);
const type = answers.resourceType === 'Collection' ? 'collections' : 'buckets';
if (!ledger.exceptions[type])
ledger.exceptions[type] = {};
const bucket = ledger.exceptions[type];
if (!bucket[answers.resourceId])
bucket[answers.resourceId] = [];
bucket[answers.resourceId].push({
rule: answers.rule.trim(),
justification: answers.justification.trim(),
author,
date: today,
});
saveSecurityLedger(appwriteDir, ledger);
console.log(chalk.green(`\n✅ Exception recorded in appwrite/security.json by '${author}' on ${today}.`));
});
exceptions
.command('list')
.description('List all security exceptions recorded in appwrite/security.json')
.action(() => {
const appwriteDir = path.join(process.cwd(), 'appwrite');
const ledger = loadSecurityLedger(appwriteDir);
const { collections = {}, buckets = {} } = ledger.exceptions;
const allEntries = [];
for (const [id, exs] of Object.entries(collections)) {
for (const ex of exs)
allEntries.push({ type: 'collection', id, ...ex });
}
for (const [id, exs] of Object.entries(buckets)) {
for (const ex of exs)
allEntries.push({ type: 'bucket', id, ...ex });
}
if (allEntries.length === 0) {
console.log(chalk.yellow('No security exceptions recorded in appwrite/security.json.'));
return;
}
console.log(chalk.bold.underline('\nSecurity Exceptions\n'));
for (const entry of allEntries) {
console.log(`${chalk.cyan(entry.type.padEnd(12))} ${chalk.bold(entry.id.padEnd(28))} ${chalk.yellow(entry.rule.padEnd(30))} ${chalk.gray(`${entry.author}, ${entry.date}`)}`);
console.log(` ${chalk.italic(entry.justification)}`);
console.log();
}
});
program.parse();
+13
-8

@@ -1,2 +0,2 @@

import { exec } from 'child_process';
import { exec, execFile as _execFile } from 'child_process';
import { promisify } from 'util';

@@ -7,2 +7,3 @@ import fs from 'fs';

const execAsync = promisify(exec);
const execFileAsync = promisify(_execFile);
const SNAPSHOT_FILENAME = 'appwrite.config.json';

@@ -13,9 +14,15 @@ /**

export const configureClient = async (config) => {
// Use execFile (not exec) to pass each argument separately — prevents command injection
// if endpoint / projectId / apiKey contain shell-special characters.
const args = [
`--endpoint ${config.endpoint}`,
`--project-id ${config.projectId}`,
`--key ${config.apiKey}`,
'client',
'--endpoint',
config.endpoint,
'--project-id',
config.projectId,
'--key',
config.apiKey,
];
try {
await execAsync(`appwrite client ${args.join(' ')}`);
await execFileAsync('appwrite', args);
console.log(chalk.green('Appwrite CLI configured successfully.'));

@@ -59,5 +66,3 @@ }

// Cleanup: Remove the root appwrite.config.json created by the pull command.
if (fs.existsSync(rootConfig)) {
fs.unlinkSync(rootConfig);
}
fs.unlinkSync(rootConfig);
return targetPath;

@@ -64,0 +69,0 @@ }

@@ -7,3 +7,2 @@ export interface AppConfig {

database: string;
backupCommand?: string;
}

@@ -10,0 +9,0 @@ /**

@@ -10,12 +10,22 @@ import dotenv from 'dotenv';

dotenv.config({ path: path.resolve(process.cwd(), envPath), override: true });
const endpoint = process.env.APPWRITE_ENDPOINT;
const projectId = process.env.APPWRITE_PROJECT_ID;
const apiKey = process.env.APPWRITE_API_KEY;
const backupCommand = process.env.BACKUP_COMMAND;
// Trim values to avoid copy-paste whitespace bugs in .env files.
const endpoint = process.env.APPWRITE_ENDPOINT?.trim();
const projectId = process.env.APPWRITE_PROJECT_ID?.trim();
const apiKey = process.env.APPWRITE_API_KEY?.trim();
if (!endpoint || !projectId || !apiKey) {
throw new Error('Missing required environment variables: APPWRITE_ENDPOINT, APPWRITE_PROJECT_ID, APPWRITE_API_KEY');
}
// Validate endpoint is a well-formed http(s) URL to prevent SSRF via misconfiguration.
try {
const url = new URL(endpoint);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('APPWRITE_ENDPOINT must use http or https protocol.');
}
}
catch {
throw new Error(`APPWRITE_ENDPOINT is not a valid URL: "${endpoint}"`);
}
// Find root directory.
const rootDir = process.cwd();
const configPath = path.join(rootDir, 'appwrite', 'migration', 'config.json');
const configPath = path.join(rootDir, 'appwrite', 'appwrite-ctl.config.json');
let migrationCollectionId = 'migrations';

@@ -37,4 +47,4 @@ let database = 'system';

}
catch (error) {
console.warn('Could not parse config.json, using defaults.');
catch {
console.warn('Could not parse appwrite-ctl.config.json, using defaults.');
}

@@ -48,4 +58,3 @@ }

database,
backupCommand,
};
};
import fs from 'fs';
import path from 'path';
import { loadSecurityLedger, getExceptions } from './security.js';
const MERMAID_CARDINALITY = {

@@ -10,2 +11,11 @@ oneToOne: '||--||',

/**
* Sanitize a string for safe embedding inside Mermaid erDiagram entity/field names.
* Braces, backticks, double-quotes, and newlines can break Mermaid's parser.
*/
const sanitizeMermaid = (value) => value
.replace(/[\r\n]+/g, ' ') // no literal newlines
.replace(/[{}]/g, '') // brace characters end entity blocks
.replace(/"/g, "'") // double-quote ends Mermaid label strings
.replace(/`/g, "'"); // backtick is a Mermaid reserved delimiter
/**
* Map Appwrite column types to concise display types for the ER diagram.

@@ -46,3 +56,3 @@ */

for (const table of tables) {
const entityName = table.name;
const entityName = sanitizeMermaid(table.name);
lines.push(` ${entityName} {`);

@@ -55,8 +65,9 @@ // Always add implicit id primary key

if (col.side === 'parent' && col.relatedTable) {
const pairKey = [entityName, col.relatedTable].sort().join(':');
const relatedName = sanitizeMermaid(col.relatedTable);
const pairKey = [entityName, relatedName].sort().join(':');
if (!renderedPairs.has(pairKey)) {
renderedPairs.add(pairKey);
const cardinality = MERMAID_CARDINALITY[col.relationType ?? 'oneToMany'] ?? '||--||';
const label = `"${col.key}"`;
relationships.push(` ${entityName} ${cardinality} ${col.relatedTable} : ${label}`);
const label = `"${sanitizeMermaid(col.key)}"`;
relationships.push(` ${entityName} ${cardinality} ${relatedName} : ${label}`);
}

@@ -67,4 +78,5 @@ }

const type = mapColumnType(col);
const colKey = sanitizeMermaid(col.key);
const comment = col.required ? '"NOT NULL"' : '';
lines.push(` ${type} ${col.key} ${comment}`.trimEnd());
lines.push(` ${type} ${colKey} ${comment}`.trimEnd());
}

@@ -81,5 +93,18 @@ lines.push(` }`);

/**
* Render security exception callout lines into a `> [!WARNING]` block.
*/
const buildSecurityCallout = (exceptions) => {
if (exceptions.length === 0)
return '';
const lines = [''];
lines.push('> [!WARNING]');
for (const ex of exceptions) {
lines.push(`> **Security Exception Acknowledged:** (\`${ex.rule}\`) — *${ex.justification}* — (Author: ${ex.author}, ${ex.date})`);
}
return lines.join('\n');
};
/**
* Build markdown documentation for a single collection.
*/
const buildCollectionDoc = (table) => {
const buildCollectionDoc = (table, exceptions = []) => {
const sections = [];

@@ -154,2 +179,5 @@ const status = table.enabled ? '🟢 Enabled' : '🔴 Disabled';

}
const callout = buildSecurityCallout(exceptions);
if (callout)
sections.push(callout);
return sections.join('\n');

@@ -160,3 +188,3 @@ };

*/
const buildBucketsDoc = (buckets) => {
const buildBucketsDoc = (buckets, ledger) => {
if (buckets.length === 0)

@@ -187,2 +215,6 @@ return '';

}
const bucketExceptions = ledger ? getExceptions(ledger, 'buckets', b.$id) : [];
const callout = buildSecurityCallout(bucketExceptions);
if (callout)
lines.push(callout);
}

@@ -197,4 +229,7 @@ return lines.join('\n');

const snapshot = JSON.parse(raw);
// Load migration config to discover the system database name
const configPath = path.join(process.cwd(), 'appwrite', 'migration', 'config.json');
// Load security ledger from appwrite/ at the project root
const appwriteDir = path.join(process.cwd(), 'appwrite');
const ledger = loadSecurityLedger(appwriteDir);
// Load appwrite-ctl config to discover the system database name
const configPath = path.join(process.cwd(), 'appwrite', 'appwrite-ctl.config.json');
let systemDbName = 'system';

@@ -237,3 +272,4 @@ if (fs.existsSync(configPath)) {

sections.push('');
sections.push(buildCollectionDoc(table));
const collectionExceptions = getExceptions(ledger, 'collections', table.$id);
sections.push(buildCollectionDoc(table, collectionExceptions));
}

@@ -244,5 +280,5 @@ }

sections.push('');
sections.push(buildBucketsDoc(snapshot.buckets));
sections.push(buildBucketsDoc(snapshot.buckets, ledger));
}
return sections.join('\n') + '\n';
};

@@ -56,4 +56,4 @@ import fs from 'fs';

}
catch (e) {
console.error(`Failed to load migration file ${validIndexFile}:`, e);
catch (loadError) {
console.error(`Failed to load migration file ${validIndexFile}:`, loadError);
process.exit(1);

@@ -71,20 +71,3 @@ }

console.log(`Applying version ${version} (${migration.id})...`);
// 3. Backup hook.
if (migration.requiresBackup && config.backupCommand) {
console.log('Running backup command...');
try {
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
await execAsync(config.backupCommand);
}
catch (error) {
console.error('Backup failed:', error);
process.exit(1);
}
}
else if (migration.requiresBackup && !config.backupCommand) {
console.warn('Migration requires backup but BACKUP_COMMAND is not set. Proceeding with caution...');
}
// 4. Schema sync via CLI push.
// 3. Schema sync via CLI push.
const snapshotPath = path.join(versionPath, snapshotFilename);

@@ -135,2 +118,3 @@ if (fs.existsSync(snapshotPath)) {

console.log('Polling attribute status...');
const MAX_ATTEMPTS = 60; // 60 × 2 s = 2-minute timeout per collection
let schema;

@@ -161,3 +145,9 @@ try {

let allAvailable = false;
let attempts = 0;
while (!allAvailable) {
if (attempts >= MAX_ATTEMPTS) {
console.warn(chalk.yellow(` ⚠ Timed out waiting for attributes on ${collectionId} after ${MAX_ATTEMPTS} attempts. Proceeding anyway.`));
break;
}
attempts++;
try {

@@ -164,0 +154,0 @@ const response = await databases.listAttributes(databaseId, collectionId);

@@ -14,3 +14,2 @@ import type { Client, Databases } from 'node-appwrite';

description?: string;
requiresBackup?: boolean;
up: MigrationFunction;

@@ -23,6 +22,21 @@ down?: MigrationFunction;

}
export interface MigrationFile {
version: string;
path: string;
content: Migration;
export interface SecurityException {
rule: string;
justification: string;
author: string;
date: string;
}
export type SecurityExceptions = Record<string, SecurityException[]>;
export type SecurityRuleSeverity = 'error' | 'warn' | 'off';
export interface SecurityRule {
enabled: boolean;
severity: SecurityRuleSeverity;
}
export type SecurityRules = Record<string, SecurityRule>;
export interface SecurityLedger {
rules?: SecurityRules;
exceptions: {
collections?: SecurityExceptions;
buckets?: SecurityExceptions;
};
}
{
"name": "appwrite-ctl",
"version": "1.0.2",
"version": "1.0.3",
"description": "Appwrite infrastructure as code and migration CLI tool.",

@@ -5,0 +5,0 @@ "repository": {

+98
-40

@@ -11,4 +11,5 @@ # Appwrite Ctl

- **State Management**: Tracks applied migrations in a dedicated Appwrite collection (`system.migrations`).
- **Backup Hooks**: Supports executing external backup commands before migration.
- **Attribute Polling**: Ensures schema attributes are `available` before running data scripts.
- **Security Rules & Exceptions Ledger**: Define security rules for collections and buckets; document intentional exceptions with author and justification — all stored in `appwrite-ctl.config.json` and surfaced in generated docs.
- **Schema Documentation**: Auto-generate ER diagrams and detailed collection docs from any snapshot.

@@ -39,3 +40,2 @@ ## Installation

APPWRITE_API_KEY=your_api_key
BACKUP_COMMAND="docker exec appwrite-mariadb mysqldump ..." # Optional
```

@@ -73,4 +73,5 @@

- `appwrite/` directory
- `appwrite/migration/` directory
- `appwrite/migration/config.json` configuration file
- `appwrite/appwrite-ctl.config.json` — unified configuration file (migration settings + security rules)

@@ -93,3 +94,4 @@ ### 2. Setup System Collection

2. Generates an `index.ts` file with a boilerplate migration script.
3. Copies the current `appwrite.config.json` from the project root (or pulls from Appwrite via CLI if no local snapshot exists).
3. Pulls the current `appwrite.config.json` from Appwrite via CLI.
4. Auto-generates `docs.md` for the new version and updates `appwrite/docs.md`.

@@ -100,13 +102,14 @@ **Folder Structure:**

/appwrite
schema.md <-- Generated by `docs` command
appwrite-ctl.config.json <-- Unified config (migration + security rules/exceptions)
appwrite.config.json <-- Appwrite CLI snapshot (latest, temporary)
docs.md <-- Generated by `docs` command
/migration
config.json
/v1
index.ts <-- Migration logic (SDK)
appwrite.config.json <-- Schema snapshot (CLI format)
schema.md <-- Auto-generated on create/update
docs.md <-- Auto-generated on create/update
/v2
index.ts
appwrite.config.json
schema.md
docs.md
```

@@ -122,3 +125,2 @@

description: 'Update finance schema',
requiresBackup: true,

@@ -160,7 +162,6 @@ up: async ({ client, databases, log }) => {

1. **Configure CLI**: Sets endpoint, project-id, and API key on appwrite-cli.
2. **Backup**: Runs `BACKUP_COMMAND` if `requiresBackup` is true.
3. **Schema Push**: Pushes the version's `appwrite.config.json` via CLI (settings, tables, buckets, teams, topics).
4. **Polling**: Waits for all schema attributes to become `available` (via SDK).
5. **Execution**: Runs the `up` function defined in `index.ts` (via SDK).
6. **Finalization**: Records the migration as applied.
2. **Schema Push**: Pushes the version's `appwrite.config.json` via CLI (tables, buckets, teams, topics).
3. **Polling**: Waits for all schema attributes to become `available` (via SDK), with a 2-minute timeout per collection.
4. **Execution**: Runs the `up` function defined in `index.ts` (via SDK).
5. **Finalization**: Records the migration as applied.

@@ -176,7 +177,7 @@ ### 7. Check Status

```bash
# Generate from latest version → appwrite/schema.md
npx appwrite-ctl migrations docs
# Pull latest state from Appwrite and generate docs → appwrite/docs.md
npx appwrite-ctl docs
# Generate from a specific version
npx appwrite-ctl migrations docs v1
# Generate from a stored local snapshot (no Appwrite connection needed)
npx appwrite-ctl docs v1
```

@@ -189,14 +190,67 @@

- **Buckets**: storage configuration summary
- **Security exception callouts** inline where exceptions have been recorded
> **Note:** Schema docs are also auto-generated inside the version folder (`vN/schema.md`) when running `migrations create` or `migrations update`.
> **Note:** Docs are also auto-generated inside the version folder (`vN/docs.md`) when running `migrations create` or `migrations update`.
## Configuration (`appwrite/migration/config.json`)
## Security Exceptions Ledger
When a resource intentionally deviates from security best-practices, document it explicitly in the `security.exceptions` block of `appwrite-ctl.config.json` — it persists across all snapshot operations.
> [!IMPORTANT]
> `appwrite-ctl.config.json` should be committed to version control — it is the team's audit trail for security exceptions.
### Integration with Docs
When `docs` (or `migrations create` / `migrations update`) generates `docs.md`, it reads `security.exceptions` and injects a `> [!WARNING]` callout after each affected collection or bucket.
### Adding Exceptions via CLI
```bash
npx appwrite-ctl exceptions add
```
Walk through the prompts — the rule is selected from the configured rules list, and the author is resolved automatically from `git config user.name` or your OS username.
### Listing Exceptions
```bash
npx appwrite-ctl exceptions list
```
Prints a formatted table of every recorded exception grouped by type and resource ID.
---
## Configuration (`appwrite/appwrite-ctl.config.json`)
All tool configuration lives in a single file at `appwrite/appwrite-ctl.config.json`. It is created automatically by `appwrite-ctl init`.
```json
{
"collection": "migrations",
"database": "system"
"database": "system",
"security": {
"rules": {
"require-row-security": { "enabled": true, "severity": "error" },
"forbid-role-all-write": { "enabled": true, "severity": "error" },
"forbid-role-all-delete": { "enabled": true, "severity": "error" },
"forbid-role-all-read": { "enabled": true, "severity": "warn" },
"forbid-role-all-create": { "enabled": true, "severity": "warn" },
"require-file-security": { "enabled": true, "severity": "warn" }
},
"exceptions": {
"collections": {},
"buckets": {}
}
}
}
```
| Field | Description |
| :-------------------- | :-------------------------------------------------------------------------------------- |
| `collection` | ID of the migrations tracking collection. |
| `database` | ID of the database where migrations are tracked (default: `system`). |
| `security.rules` | Map of rule IDs to `{ enabled, severity }`. Severity: `"error"` \| `"warn"` \| `"off"`. |
| `security.exceptions` | Documented bypasses per resource (see **Security Exceptions Ledger** above). |
## CI/CD & Automated Deployment

@@ -217,11 +271,13 @@

| Command | Description |
| :---------------------------- | :----------------------------------------------------------------------------------- |
| `init` | Initialize the project folder structure and config. |
| `migrations setup` | Create the `system` database and `migrations` collection. |
| `migrations create` | Create a new migration version pulling the latest snapshot from Appwrite via CLI. |
| `migrations update <version>` | Update a version's snapshot by pulling from Appwrite via CLI. |
| `migrations run` | Execute all pending migrations in order. |
| `migrations status` | List applied and pending migrations. |
| `migrations docs` | Pull current schema state from Appwrite and generate documentation with ER diagrams. |
| Command | Description |
| :---------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `init` | Initialize the project folder structure and config. |
| `migrations setup` | Create the `system` database and `migrations` collection. |
| `migrations create` | Create a new migration version pulling the latest snapshot from Appwrite via CLI. |
| `migrations update <version>` | Update a version's snapshot by pulling from Appwrite via CLI. |
| `migrations run` | Execute all pending migrations in order. |
| `migrations status` | List applied and pending migrations. |
| `docs [version]` | Generate `docs.md`. Without a version, pulls live from Appwrite. With a version (e.g. `v1`), reads the stored local snapshot — no Appwrite connection needed. |
| `exceptions add` | Interactively add a security exception entry to `appwrite-ctl.config.json`. |
| `exceptions list` | List all security exceptions recorded in `appwrite-ctl.config.json`. |

@@ -232,3 +288,3 @@ # AI Rules

📌 `schema.md` — The Source of Truth
📌 `docs.md` — The Source of Truth

@@ -238,8 +294,8 @@ The most important file for understanding the application's **data model** is:

```
appwrite/schema.md
appwrite/docs.md
```
This is an **auto-generated** Markdown file that documents the **current state** of every database, collection, attribute, relationship, index, and storage bucket in the Appwrite project. It is generated from the latest `appwrite.config.json` snapshot via the `migrations docs` command.
This is an **auto-generated** Markdown file that documents the **current state** of every database, collection, attribute, relationship, index, and storage bucket in the Appwrite project. It is generated from the latest `appwrite.config.json` snapshot via the `docs` command.
**When you need to understand the data model — always read `appwrite/schema.md` first.**
**When you need to understand the data model — always read `appwrite/docs.md` first.**

@@ -255,2 +311,3 @@ It contains:

- **Buckets** — storage buckets with max file size, extensions, compression, encryption, and antivirus settings.
- **Security exception callouts** — `[!WARNING]` blocks embedded next to any resource with a recorded bypass.

@@ -267,3 +324,4 @@ ## Migration Commands

| `appwrite-ctl migrations status` | List applied and pending migrations. |
| `appwrite-ctl migrations docs` | Pull current schema state from Appwrite and generate/regenerate `schema.md`. |
| `appwrite-ctl docs` | Pull the current Appwrite state and generate/regenerate `docs.md`. |
| `appwrite-ctl docs <version>` | Generate `docs.md` from a stored local snapshot (no Appwrite connection needed). |

@@ -274,3 +332,3 @@ Each migration version lives in `appwrite/migration/vN/` and contains:

- **`index.ts`** — the migration script with `up` (and optional `down`) functions.
- **`schema.md`** — auto-generated docs for that version's snapshot.
- **`docs.md`** — auto-generated docs for that version's snapshot.

@@ -296,9 +354,9 @@ ## How to Handle Data Model Changes

```bash
npx appwrite-ctl migrations docs
npx appwrite-ctl docs
```
This updates both `appwrite/migration/vN/schema.md` and the root `appwrite/schema.md`.
This updates `appwrite/docs.md` from the latest Appwrite state.
5. **Verify** the updated `appwrite/schema.md` to confirm the changes are correct.
5. **Verify** the updated `appwrite/docs.md` to confirm the changes are correct.
> ⚠️ **Never edit `schema.md` files manually** — they are auto-generated. Always modify the `appwrite.config.json` snapshot and run `migrations docs` to regenerate.
> ⚠️ **Never edit `docs.md` files manually** — they are auto-generated. Always modify the `appwrite.config.json` snapshot and run `docs` to regenerate.