|
| 1 | +import { mkdir, readFile, writeFile } from "node:fs/promises"; |
| 2 | +import path from "node:path"; |
| 3 | +import { businessPath, businessRoot } from "./lib/paths.mjs"; |
| 4 | + |
| 5 | +const root = businessRoot(); |
| 6 | +const business = await readBusinessProfile(); |
| 7 | +const actionType = readArg("--action-type") ?? "email"; |
| 8 | +const title = readArg("--title") ?? "Send consultation follow-up"; |
| 9 | +const target = readArg("--target") ?? "Customer follow-up"; |
| 10 | +const evidence = readArg("--evidence") ?? "Owner-provided customer notes"; |
| 11 | +const outputDir = businessPath("connected-actions"); |
| 12 | +const slug = slugify(`${actionType}-${title}`); |
| 13 | +const markdownFile = path.join(outputDir, `${slug}-receipt.md`); |
| 14 | +const jsonFile = path.join(outputDir, `${slug}-receipt.json`); |
| 15 | +const record = connectedActionRecord({ actionType, title, target, evidence }); |
| 16 | + |
| 17 | +await mkdir(outputDir, { recursive: true }); |
| 18 | +await writeFile(jsonFile, `${JSON.stringify(record, null, 2)}\n`); |
| 19 | +await writeFile(markdownFile, renderReceipt({ record, markdownFile, jsonFile })); |
| 20 | + |
| 21 | +console.log(`Prepared connected-action receipt: ${markdownFile}`); |
| 22 | +console.log("Mode: dry-run only"); |
| 23 | +console.log("Execution: blocked until owner approval and connected-mode preflight"); |
| 24 | + |
| 25 | +function readArg(name) { |
| 26 | + const index = process.argv.indexOf(name); |
| 27 | + if (index === -1) return null; |
| 28 | + return process.argv[index + 1] ?? null; |
| 29 | +} |
| 30 | + |
| 31 | +async function readBusinessProfile() { |
| 32 | + const profilePath = businessPath("business.json"); |
| 33 | + try { |
| 34 | + return JSON.parse(await readFile(profilePath, "utf8")); |
| 35 | + } catch (error) { |
| 36 | + if (error && error.code === "ENOENT") { |
| 37 | + throw new Error(`No business workspace found at ${profilePath}. Run npm run bootstrap first.`); |
| 38 | + } |
| 39 | + throw error; |
| 40 | + } |
| 41 | +} |
| 42 | + |
| 43 | +function connectedActionRecord({ actionType, title, target, evidence }) { |
| 44 | + const normalizedActionType = slugify(actionType); |
| 45 | + const normalizedTitle = slugify(title); |
| 46 | + const refBase = `${normalizedActionType}-${normalizedTitle}`; |
| 47 | + |
| 48 | + return { |
| 49 | + schema: "frontsmith.connected_action_receipt.v0.1", |
| 50 | + mode: "dry_run", |
| 51 | + business: { |
| 52 | + name: business.businessName, |
| 53 | + workspace: ".frontsmith/business" |
| 54 | + }, |
| 55 | + proposed_action: { |
| 56 | + action_ref: `frontsmith-action-${refBase}`, |
| 57 | + type: actionType, |
| 58 | + title, |
| 59 | + target, |
| 60 | + evidence, |
| 61 | + requested_by: "owner_or_operator", |
| 62 | + execution_default: "blocked" |
| 63 | + }, |
| 64 | + authority_refs: { |
| 65 | + neura_registry_ref: `registry-ref-pending-${refBase}`, |
| 66 | + neura_relay_action_card_ref: `relay-action-card-pending-${refBase}`, |
| 67 | + neura_relay_decision_receipt_ref: "pending_until_connected_mode_preflight", |
| 68 | + trace_ref: `frontsmith-trace-${refBase}` |
| 69 | + }, |
| 70 | + approval_gate: { |
| 71 | + owner_approval_required: true, |
| 72 | + approval_status: "not_approved", |
| 73 | + required_before_execution: [ |
| 74 | + "owner approval of the exact action", |
| 75 | + "confirmed target and payload", |
| 76 | + "confirmed connector and account", |
| 77 | + "connected-mode Neura Relay preflight when enabled", |
| 78 | + "receipt review before adapter execution" |
| 79 | + ] |
| 80 | + }, |
| 81 | + adapter_boundary: { |
| 82 | + live_adapter_enabled: false, |
| 83 | + blocked_adapters: ["email", "publish", "file", "calendar", "provider_setup"], |
| 84 | + reason: "Frontsmith records the proposed connected action locally before any live provider action is allowed." |
| 85 | + }, |
| 86 | + created_at: new Date().toISOString() |
| 87 | + }; |
| 88 | +} |
| 89 | + |
| 90 | +function renderReceipt({ record, markdownFile, jsonFile }) { |
| 91 | + return `# Connected-Action Receipt |
| 92 | +
|
| 93 | +Business: ${record.business.name} |
| 94 | +Status: Dry run, needs owner review, not executed |
| 95 | +Generated: ${record.created_at} |
| 96 | +
|
| 97 | +## Executive Summary |
| 98 | +
|
| 99 | +Frontsmith prepared a local receipt-style record for a proposed connected action. No provider adapter ran. No email was sent, website was published, file was uploaded, calendar event was created, provider was configured, or external action was executed. |
| 100 | +
|
| 101 | +## Proposed Action |
| 102 | +
|
| 103 | +- Action ref: \`${record.proposed_action.action_ref}\` |
| 104 | +- Type: ${record.proposed_action.type} |
| 105 | +- Title: ${record.proposed_action.title} |
| 106 | +- Target: ${record.proposed_action.target} |
| 107 | +- Evidence: ${record.proposed_action.evidence} |
| 108 | +- Execution default: ${record.proposed_action.execution_default} |
| 109 | +
|
| 110 | +## Neura References |
| 111 | +
|
| 112 | +- Registry ref: \`${record.authority_refs.neura_registry_ref}\` |
| 113 | +- Relay Action Card ref: \`${record.authority_refs.neura_relay_action_card_ref}\` |
| 114 | +- Relay Decision Receipt ref: \`${record.authority_refs.neura_relay_decision_receipt_ref}\` |
| 115 | +- Trace ref: \`${record.authority_refs.trace_ref}\` |
| 116 | +
|
| 117 | +These refs are local placeholders until connected client mode is approved and a real Neura Relay preflight is run. |
| 118 | +
|
| 119 | +## Approval Gate |
| 120 | +
|
| 121 | +- Owner approval required: ${record.approval_gate.owner_approval_required ? "yes" : "no"} |
| 122 | +- Approval status: ${record.approval_gate.approval_status} |
| 123 | +
|
| 124 | +Required before execution: |
| 125 | +
|
| 126 | +${record.approval_gate.required_before_execution.map((item) => `- ${item}`).join("\n")} |
| 127 | +
|
| 128 | +## Adapter Boundary |
| 129 | +
|
| 130 | +- Live adapter enabled: ${record.adapter_boundary.live_adapter_enabled ? "yes" : "no"} |
| 131 | +- Blocked adapters: ${record.adapter_boundary.blocked_adapters.join(", ")} |
| 132 | +- Reason: ${record.adapter_boundary.reason} |
| 133 | +
|
| 134 | +## Artifacts |
| 135 | +
|
| 136 | +- Markdown receipt: \`${displayPath(markdownFile)}\` |
| 137 | +- JSON receipt: \`${displayPath(jsonFile)}\` |
| 138 | +- Business profile: \`${displayPath(businessPath("business.json"))}\` |
| 139 | +
|
| 140 | +## Safety Boundary |
| 141 | +
|
| 142 | +This connected-action receipt is local-only. It does not send email, publish the website, change DNS, create calendar events, upload files, export data, configure providers, or execute connected actions. |
| 143 | +`; |
| 144 | +} |
| 145 | + |
| 146 | +function slugify(value) { |
| 147 | + return String(value) |
| 148 | + .toLowerCase() |
| 149 | + .replace(/&/g, " and ") |
| 150 | + .replace(/[^a-z0-9]+/g, "-") |
| 151 | + .replace(/^-+|-+$/g, "") |
| 152 | + .slice(0, 80); |
| 153 | +} |
| 154 | + |
| 155 | +function displayPath(targetFile) { |
| 156 | + const normalizedFile = path.normalize(targetFile); |
| 157 | + const normalizedRoot = path.normalize(root); |
| 158 | + const relativeToBusiness = path.relative(normalizedRoot, normalizedFile); |
| 159 | + |
| 160 | + if (relativeToBusiness && !relativeToBusiness.startsWith("..") && !path.isAbsolute(relativeToBusiness)) { |
| 161 | + return toPosix(path.join(".frontsmith", "business", relativeToBusiness)); |
| 162 | + } |
| 163 | + |
| 164 | + const relativeToProject = path.relative(process.cwd(), normalizedFile) || normalizedFile; |
| 165 | + return toPosix(relativeToProject); |
| 166 | +} |
| 167 | + |
| 168 | +function toPosix(value) { |
| 169 | + return value.split(path.sep).join("/"); |
| 170 | +} |
0 commit comments