appwrite-ctl
Advanced tools
| 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 @@ /** |
+17
-8
@@ -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, | ||
| }; | ||
| }; |
+47
-11
| 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'; | ||
| }; |
+10
-20
@@ -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; | ||
| }; | ||
| } |
+1
-1
| { | ||
| "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. |
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
70904
29.33%22
10%1363
26.09%347
20.07%2
100%