Skill: solarspiker/appian-unnamedobjects (version 1.3.0)

--- name: appian-unnamedobjects description: Find Appian application objects that are missing a description. Exports the application, scans all object XML files, and reports name and UUID for each object with an empty or absent description tag. Use after changes to an application to audit documentation coverage. Trigger phrases — "find Appian objects without descriptions", "which Appian objects are undocumented", "audit Appian missing descriptions", "list undescribed Appian objects", "check Appian documentation coverage". IMPORTANT — credentials (APPIAN_BASE_URL, APPIAN_API_KEY) are already configured in the system; do NOT ask the user for them before running. metadata: clawdbot: emoji: "🔎" requires: env: - APPIAN_BASE_URL - APPIAN_API_KEY primaryEnv: APPIAN_BASE_URL --- # Appian Unnamed Objects Exports an Appian application and scans every object XML file for objects whose `<description>` tag is empty or missing. Reports type, name, and UUID for each offending object. ## Usage ```bash node {baseDir}/scripts/index.js <applicationUuid> ``` ## Example ```bash node {baseDir}/scripts/index.js _a-0000de15-1f1c-8000-5130-010000010000_12559 ``` ## IMPORTANT: credentials are pre-configured `APPIAN_BASE_URL` and `APPIAN_API_KEY` are already injected by OpenClaw at runtime. **Never ask the user for credentials before running this skill.** Just execute it with the UUID the user provided. ## How users can ask for this - "Find Appian objects without descriptions in application `<uuid>`" - "Which Appian objects are undocumented in `<uuid>`" - "Audit Appian missing descriptions in `<uuid>`" - "Check Appian documentation coverage for `<uuid>`" ## What it does 1. Calls the Appian v2 Deployment Management API to export the application as a ZIP. 2. Parses the ZIP in-process (no shell extraction) using Node.js built-ins — one object per XML file, type from directory name. 3. Iterates every XML file outside `META-INF/`. 4. For each object, checks whether `<description>` or `<a:description>` is absent or blank. 5. Prints a grouped report of offending objects. ## After running **Relay the full skill output to the user exactly as printed — do not summarize, paraphrase, or omit any lines.** The output already contains every object type, name, and UUID in a compact readable format. Your job is to forward it verbatim, then offer to help further. Do not replace the list with a vague count like "15 objects were found" — the user needs the actual names and details. ## External endpoints - `POST ${APPIAN_BASE_URL}/deployments` — triggers the export - `GET ${APPIAN_BASE_URL}/deployments/{uuid}` — polls for completion - `GET <packageZip URL>` — downloads the resulting ZIP ## Security - Credentials (`APPIAN_BASE_URL`, `APPIAN_API_KEY`) are read from environment variables (injected by OpenClaw at runtime). If not injected, falls back to `appian.json` in the current working directory. - The ZIP is written to `~/appian-exports/` and mirrored to `CWD/appian-exports/` when running in a container. - No data is sent to any third-party service. - No shell commands are executed; ZIP extraction uses Node.js built-in `zlib`.

path: scripts/index.js
content: /**
 * @skill       appian-unnamedobjects
 * @version     1.3.0
 * @description Export an Appian application and report all objects whose
 *              <description> tag is absent or empty.
 *
 * @security-manifest
 *   env-accessed:       APPIAN_BASE_URL, APPIAN_API_KEY
 *   external-endpoints: POST ${APPIAN_BASE_URL}/deployments, GET ${APPIAN_BASE_URL}/deployments/{uuid}, GET <packageZip URL from Appian>
 *   file-operations:    writes ZIP to ~/appian-exports/ and CWD/appian-exports/ (if in container)
 *   no-shell-execution: true
 *   no-third-party-exfiltration: true
 */
import fs   from 'node:fs';
import os   from 'node:os';
import path from 'node:path';
import zlib from 'node:zlib';

// ── Phase 1: Credential loading ────────────────────────────────────────────
// Credentials are resolved and validated before any file or network operations.

function loadEnv(configPath) {
    try {
        const raw = fs.readFileSync(configPath, 'utf8');
        let config;
        try { config = JSON.parse(raw); }
        catch {
            config = {};
            for (const line of raw.split('\n')) {
                const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.+)$/);
                if (m) config[m[1]] = m[2].trim();
            }
        }
        for (const [key, val] of Object.entries(config)) {
            if (!process.env[key]) process.env[key] = String(val);
        }
    } catch { /* rely on inherited env */ }
}

function validateCredentials() {
    let dir = process.cwd();
    for (let i = 0; i < 5; i++) {
        const candidate = path.join(dir, 'appian.json');
        if (fs.existsSync(candidate)) { loadEnv(candidate); break; }
        const parent = path.dirname(dir);
        if (parent === dir) break;
        dir = parent;
    }
    const baseUrl = process.env.APPIAN_BASE_URL?.replace(/\/$/, '');
    const [REDACTED_SECRET];
    if (!baseUrl) throw new Error('APPIAN_BASE_URL is not set');
    if (!apiKey)  throw new Error('APPIAN_API_KEY is not set');
    return { baseUrl, apiKey };
}

// ── Phase 2: Payload preparation ───────────────────────────────────────────
// Build the export request payload. No network calls or file I/O here.

function prepareExportPayload(uuid) {
    const formData = new FormData();
    formData.append('json', JSON.stringify({ uuids: [uuid], exportType: 'application' }));
    return formData;
}

// ── Phase 3: Network ───────────────────────────────────────────────────────
// All network calls happen here.

async function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

async function sendExport(credentials, formData) {
    const res = await fetch(`${credentials.baseUrl}/deployments`, {
        method:  'POST',
        headers: { 'appian-api-key': credentials.apiKey, 'Action-Type': 'export' },
        body:    formData,
    });
    if (!res.ok) {
        const text = await res.text().catch(() => '');
        throw new Error(`Export trigger failed [${res.status}]: ${text}`);
    }
    const data = await res.json();
    if (!data.uuid) throw new Error(`No uuid in trigger response`);
    return data;
}

async function pollExportStatus(credentials, deploymentUuid) {
    const pollUrl   = `${credentials.baseUrl}/deployments/${deploymentUuid}`;
    const COMPLETED = new Set(['COMPLETED', 'COMPLETED_WITH_IMPORT_ERRORS', 'COMPLETED_WITH_PUBLISH_ERRORS']);
    const FAILED    = new Set(['FAILED', 'PENDING_REVIEW', 'REJECTED']);
    let status = '';
    let poll   = {};
    for (let i = 1; i <= 60; i++) {
        await sleep(5000);
        const r = await fetch(pollUrl, { headers: { 'appian-api-key': credentials.apiKey } });
        if (!r.ok) { process.stderr.write(`Poll ${i} failed [${r.status}]\n`); continue; }
        poll   = await r.json();
        status = poll.status ?? '';
        process.stderr.write(`[${i}/60] ${status}\n`);
        if (COMPLETED.has(status) || FAILED.has(status)) break;
    }
    if (FAILED.has(status))     throw new Error(`Export failed (${status})`);
    if (!COMPLETED.has(status)) throw new Error(`Timed out waiting for export. Last: ${status}`);
    return poll;
}

async function downloadZip(credentials, zipUrl) {
    const res = await fetch(zipUrl, { headers: { 'appian-api-key': credentials.apiKey } });
    if (!res.ok) throw new Error(`Download failed [${res.status}]`);
    const cd      = res.headers.get('content-disposition') ?? '';
    const fnMatch = cd.match(/filename[^;=\n]*=(['"]?)([^\n"';]+)\1/);
    const rawName = fnMatch?.[2]?.trim() ?? null;
    const buf     = Buffer.from(await res.arrayBuffer());
    return { buf, rawName };
}

// ── Phase 4: File write ────────────────────────────────────────────────────
// Network download is complete; now persist to disk.

function saveZip(buf, rawName, deploymentUuid) {
    const storagePath = path.join(os.homedir(), 'appian-exports');
    const zipName     = rawName
        ? (rawName.endsWith('.zip') ? rawName : `${rawName}.zip`)
        : `appian-export-${deploymentUuid}.zip`;
    const outPath = path.join(storagePath, zipName);
    fs.mkdirSync(storagePath, { recursive: true });
    fs.writeFileSync(outPath, buf);
    process.stderr.write(`ZIP: ${outPath} (${(buf.length / 1024).toFixed(1)} KB)\n`);
    const cwd = process.cwd();
    if (cwd !== storagePath) {
        const cwdExp = path.join(cwd, 'appian-exports');
        fs.mkdirSync(cwdExp, { recursive: true });
        fs.copyFileSync(outPath, path.join(cwdExp, zipName));
    }
}

// ── ZIP reader ─────────────────────────────────────────────────────────────────
// Reads from the Central Directory (end of file) so it works with Java-generated
// ZIPs that write data descriptors and leave compSz=0 in local file headers.

function readZipEntries(buf) {
    let eocd = -1;
    for (let i = buf.length - 22; i >= Math.max(0, buf.length - 66557); i--) {
        if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
    }
    if (eocd === -1) throw new Error('Invalid ZIP: EOCD record not found');

    const cdCount  = buf.readUInt16LE(eocd + 10);
    const cdOffset = buf.readUInt32LE(eocd + 16);
    const entries  = [];
    let pos = cdOffset;

    for (let i = 0; i < cdCount; i++) {
        if (buf.readUInt32LE(pos) !== 0x02014b50) break; // central dir signature
        const method     = buf.readUInt16LE(pos + 10);
        const compSz     = buf.readUInt32LE(pos + 20);
        const nameLen    = buf.readUInt16LE(pos + 28);
        const extraLen   = buf.readUInt16LE(pos + 30);
        const commentLen = buf.readUInt16LE(pos + 32);
        const lhOffset   = buf.readUInt32LE(pos + 42);
        const name       = buf.subarray(pos + 46, pos + 46 + nameLen).toString('utf8');
        pos += 46 + nameLen + extraLen + commentLen;

        if (name.endsWith('/')) continue; // directory entry

        const lhNameLen  = buf.readUInt16LE(lhOffset + 26);
        const lhExtraLen = buf.readUInt16LE(lhOffset + 28);
        const dataStart  = lhOffset + 30 + lhNameLen + lhExtraLen;
        const compressed = buf.subarray(dataStart, dataStart + compSz);

        let content = null;
        if      (method === 0) content = compressed.toString('utf8');
        else if (method === 8) {
            try { content = zlib.inflateRawSync(compressed).toString('utf8'); } catch { /* skip corrupt */ }
        }
        if (content !== null) entries.push({ name, content });
    }
    return entries;
}

// ── XML helpers ────────────────────────────────────────────────────────────────

function parseEntry(xml, entryPath) {
    const parts = entryPath.split('/');
    const type  = parts.length >= 2 ? parts[parts.length - 2] : 'unknown';
    const uuid  = parts[parts.length - 1].replace(/\.xml$/i, '');

    // Name — attribute on the element that owns a:uuid first (recordType, webApi…),
    // then child <name> tag (group, rulesFolder, connectedSystem…).
    // Priority matters: some types have a stray <name> deep inside schema definitions.
    let name = '';
    const attrM =
        xml.match(/<\w[^>]*a:uuid="[^"]*"[^>]*\bname="([^"]*)"/i) ||
        xml.match(/<\w[^>]*\bname="([^"]*)"[^>]*a:uuid="[^"]*"/i);
    if (attrM) {
        name = attrM[1].trim();
    } else {
        const nameTagM = xml.match(/<name>([^<]*)<\/name>/i);
        if (nameTagM) name = nameTagM[1].trim();
    }

    // Description — <description> or <a:description>, with optional CDATA.
    // Returns null when tag is absent, '' when present but empty.
    const descM = xml.match(
        /<(?:a:)?description(?:\s[^>]*)?>(?:<!\[CDATA\[([\s\S]*?)\]\]>|([^<]*))<\/(?:a:)?description>/i
    );
    const description = descM !== null ? (descM[1] ?? descM[2] ?? '').trim() : null;

    return { type, uuid, name, description };
}

// ── Main ───────────────────────────────────────────────────────────────────────

async function findUnnamedObjects(applicationUuid) {
    // Phase 1 — credentials
    const credentials = validateCredentials();

    // Phase 2 — payload (no I/O, no network)
    const formData = prepareExportPayload(applicationUuid);
    process.stderr.write(`Exporting application ${applicationUuid}...\n`);

    // Phase 3 — network
    const triggerData = await sendExport(credentials, formData);
    process.stderr.write(`deploymentUuid: ${triggerData.uuid}\n`);

    const pollData = await pollExportStatus(credentials, triggerData.uuid);
    if (!pollData.packageZip) throw new Error('No packageZip URL in response');

    const { buf, rawName } = await downloadZip(credentials, pollData.packageZip);

    // Phase 4 — write ZIP to disk
    saveZip(buf, rawName, triggerData.uuid);

    // Analysis — scan ZIP entries from in-memory buf
    const entries  = readZipEntries(buf);
    const xmlFiles = entries.filter(e =>
        e.name.toLowerCase().endsWith('.xml') && !e.name.includes('META-INF')
    );
    process.stderr.write(`Scanning ${xmlFiles.length} XML file(s) for missing descriptions...\n`);

    const missing = [];
    for (const f of xmlFiles) {
        const obj = parseEntry(f.content, f.name);
        if (obj.description === null || obj.description === '') {
            missing.push({ type: obj.type, name: obj.name, uuid: obj.uuid });
        }
    }

    if (missing.length === 0) {
        console.log('✓ All objects have descriptions — nothing to report.');
    } else {
        console.log(`Found ${missing.length} object(s) missing a description:\n`);

        for (const o of missing) {
            console.log(`[${o.type}] ${o.name || '(no name)'} | ${o.uuid}`);
        }

        const byType = {};
        for (const o of missing) {
            byType[o.type] = (byType[o.type] ?? 0) + 1;
        }
        console.log('\n--- By object type ---');
        for (const [type, count] of Object.entries(byType).sort()) {
            console.log(`  ${type}: ${count}`);
        }
    }

    return missing;
}

// CLI entry point
// Usage: node index.js <applicationUuid>
const applicationUuid = process.argv[2];
if (!applicationUuid) {
    console.error('Usage: node index.js <applicationUuid>');
    process.exit(1);
}

findUnnamedObjects(applicationUuid)
    .then(() => {})
    .catch(err => { console.error('\nError:', err.message); process.exit(1); });


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: clean (LOW)