optimal-cli 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +18 -0
- package/.claude-plugin/plugin.json +10 -0
- package/.env.example +17 -0
- package/CLAUDE.md +67 -0
- package/COMMANDS.md +264 -0
- package/PUBLISH.md +70 -0
- package/agents/content-ops.md +2 -2
- package/agents/financial-ops.md +2 -2
- package/agents/infra-ops.md +2 -2
- package/apps/.gitkeep +0 -0
- package/bin/optimal.ts +278 -591
- package/docs/MIGRATION_NEEDED.md +37 -0
- package/docs/plans/.gitkeep +0 -0
- package/docs/plans/optimal-cli-config-registry-v1.md +71 -0
- package/hooks/.gitkeep +0 -0
- package/lib/config/registry.ts +5 -4
- package/lib/kanban-obsidian.ts +232 -0
- package/lib/kanban-sync.ts +258 -0
- package/lib/kanban.ts +239 -0
- package/lib/obsidian-tasks.ts +231 -0
- package/package.json +5 -19
- package/pnpm-workspace.yaml +3 -0
- package/scripts/check-table.ts +24 -0
- package/scripts/create-tables.ts +94 -0
- package/scripts/migrate-kanban.sh +28 -0
- package/scripts/migrate-v2.ts +78 -0
- package/scripts/migrate.ts +79 -0
- package/scripts/run-migration.ts +59 -0
- package/scripts/seed-board.ts +203 -0
- package/scripts/test-kanban.ts +21 -0
- package/skills/audit-financials/SKILL.md +33 -0
- package/skills/board-create/SKILL.md +28 -0
- package/skills/board-update/SKILL.md +27 -0
- package/skills/board-view/SKILL.md +27 -0
- package/skills/delete-batch/SKILL.md +77 -0
- package/skills/deploy/SKILL.md +40 -0
- package/skills/diagnose-months/SKILL.md +68 -0
- package/skills/distribute-newsletter/SKILL.md +58 -0
- package/skills/export-budget/SKILL.md +44 -0
- package/skills/export-kpis/SKILL.md +52 -0
- package/skills/generate-netsuite-template/SKILL.md +51 -0
- package/skills/generate-newsletter/SKILL.md +53 -0
- package/skills/generate-newsletter-insurance/SKILL.md +59 -0
- package/skills/generate-social-posts/SKILL.md +67 -0
- package/skills/health-check/SKILL.md +42 -0
- package/skills/ingest-transactions/SKILL.md +51 -0
- package/skills/manage-cms/SKILL.md +50 -0
- package/skills/manage-scenarios/SKILL.md +83 -0
- package/skills/migrate-db/SKILL.md +79 -0
- package/skills/preview-newsletter/SKILL.md +50 -0
- package/skills/project-budget/SKILL.md +60 -0
- package/skills/publish-blog/SKILL.md +70 -0
- package/skills/publish-social-posts/SKILL.md +70 -0
- package/skills/rate-anomalies/SKILL.md +62 -0
- package/skills/scrape-ads/SKILL.md +49 -0
- package/skills/stamp-transactions/SKILL.md +62 -0
- package/skills/upload-income-statements/SKILL.md +54 -0
- package/skills/upload-netsuite/SKILL.md +56 -0
- package/skills/upload-r1/SKILL.md +45 -0
- package/supabase/.temp/cli-latest +1 -0
- package/supabase/migrations/.gitkeep +0 -0
- package/supabase/migrations/20250305000001_create_agent_configs.sql +36 -0
- package/supabase/migrations/20260305111300_create_cli_config_registry.sql +22 -0
- package/supabase/migrations/20260306195000_create_kanban_tables.sql +97 -0
- package/tests/config-command-smoke.test.ts +395 -0
- package/tests/config-registry.test.ts +173 -0
- package/tsconfig.json +19 -0
- package/agents/profiles.json +0 -5
- package/docs/CLI-REFERENCE.md +0 -361
- package/lib/assets/index.ts +0 -225
- package/lib/assets.ts +0 -124
- package/lib/auth/index.ts +0 -189
- package/lib/board/index.ts +0 -309
- package/lib/board/types.ts +0 -124
- package/lib/bot/claim.ts +0 -43
- package/lib/bot/coordinator.ts +0 -254
- package/lib/bot/heartbeat.ts +0 -37
- package/lib/bot/index.ts +0 -9
- package/lib/bot/protocol.ts +0 -99
- package/lib/bot/reporter.ts +0 -42
- package/lib/bot/skills.ts +0 -81
- package/lib/errors.ts +0 -129
- package/lib/format.ts +0 -120
- package/lib/returnpro/validate.ts +0 -154
- package/lib/social/meta.ts +0 -228
package/lib/format.ts
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Lightweight CLI output formatting — ANSI colors, tables, badges.
|
|
3
|
-
* Zero external dependencies. Respects NO_COLOR env var.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
// ── ANSI escape codes ───────────────────────────────────────────────
|
|
7
|
-
|
|
8
|
-
const CODES: Record<string, [number, number]> = {
|
|
9
|
-
red: [31, 39],
|
|
10
|
-
green: [32, 39],
|
|
11
|
-
yellow: [33, 39],
|
|
12
|
-
blue: [34, 39],
|
|
13
|
-
cyan: [36, 39],
|
|
14
|
-
gray: [90, 39],
|
|
15
|
-
bold: [1, 22],
|
|
16
|
-
dim: [2, 22],
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
type Color = 'red' | 'green' | 'yellow' | 'blue' | 'cyan' | 'gray' | 'bold' | 'dim'
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Wrap text in ANSI escape codes for the given color/style.
|
|
23
|
-
* Returns plain text when NO_COLOR env var is set.
|
|
24
|
-
*/
|
|
25
|
-
export function colorize(text: string, color: Color): string {
|
|
26
|
-
if (process.env.NO_COLOR !== undefined) return text
|
|
27
|
-
const [open, close] = CODES[color]
|
|
28
|
-
return `\x1b[${open}m${text}\x1b[${close}m`
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// ── Table rendering ─────────────────────────────────────────────────
|
|
32
|
-
|
|
33
|
-
/** Strip ANSI escape sequences to measure visible string width. */
|
|
34
|
-
function stripAnsi(s: string): string {
|
|
35
|
-
return s.replace(/\x1b\[\d+m/g, '')
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Render a bordered ASCII table with auto-sized columns.
|
|
40
|
-
* Headers are rendered in bold.
|
|
41
|
-
*/
|
|
42
|
-
export function table(headers: string[], rows: string[][]): string {
|
|
43
|
-
// Compute column widths from headers and all rows
|
|
44
|
-
const colWidths = headers.map((h, i) => {
|
|
45
|
-
let max = stripAnsi(h).length
|
|
46
|
-
for (const row of rows) {
|
|
47
|
-
const cell = row[i] ?? ''
|
|
48
|
-
const len = stripAnsi(cell).length
|
|
49
|
-
if (len > max) max = len
|
|
50
|
-
}
|
|
51
|
-
return max
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
function padCell(cell: string, width: number): string {
|
|
55
|
-
const visible = stripAnsi(cell).length
|
|
56
|
-
return cell + ' '.repeat(Math.max(0, width - visible))
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const sep = '+-' + colWidths.map(w => '-'.repeat(w)).join('-+-') + '-+'
|
|
60
|
-
const headerRow = '| ' + headers.map((h, i) => padCell(colorize(h, 'bold'), colWidths[i])).join(' | ') + ' |'
|
|
61
|
-
|
|
62
|
-
const bodyRows = rows.map(row =>
|
|
63
|
-
'| ' + row.map((cell, i) => padCell(cell ?? '', colWidths[i])).join(' | ') + ' |'
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
return [sep, headerRow, sep, ...bodyRows, sep].join('\n')
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// ── Status & priority badges ────────────────────────────────────────
|
|
70
|
-
|
|
71
|
-
const STATUS_COLORS: Record<string, Color> = {
|
|
72
|
-
done: 'green',
|
|
73
|
-
in_progress: 'blue',
|
|
74
|
-
blocked: 'red',
|
|
75
|
-
ready: 'cyan',
|
|
76
|
-
backlog: 'gray',
|
|
77
|
-
cancelled: 'dim',
|
|
78
|
-
review: 'yellow',
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Return a colored status string (e.g. "done" in green). */
|
|
82
|
-
export function statusBadge(status: string): string {
|
|
83
|
-
const color = STATUS_COLORS[status] ?? 'dim'
|
|
84
|
-
return colorize(status, color)
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const PRIORITY_COLORS: Record<number, Color> = {
|
|
88
|
-
1: 'red',
|
|
89
|
-
2: 'yellow',
|
|
90
|
-
3: 'blue',
|
|
91
|
-
4: 'gray',
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Return a colored priority label (e.g. "P1" in red). */
|
|
95
|
-
export function priorityBadge(p: number): string {
|
|
96
|
-
const color = PRIORITY_COLORS[p] ?? 'gray'
|
|
97
|
-
return colorize(`P${p}`, color)
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// ── Logging helpers ─────────────────────────────────────────────────
|
|
101
|
-
|
|
102
|
-
/** Print a green success message with a check mark prefix. */
|
|
103
|
-
export function success(msg: string): void {
|
|
104
|
-
console.log(`${colorize('\u2713', 'green')} ${msg}`)
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Print a red error message with an X prefix. */
|
|
108
|
-
export function error(msg: string): void {
|
|
109
|
-
console.error(`${colorize('\u2717', 'red')} ${msg}`)
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Print a yellow warning message with a warning prefix. */
|
|
113
|
-
export function warn(msg: string): void {
|
|
114
|
-
console.warn(`${colorize('\u26a0', 'yellow')} ${msg}`)
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Print a blue info message with an info prefix. */
|
|
118
|
-
export function info(msg: string): void {
|
|
119
|
-
console.log(`${colorize('\u2139', 'blue')} ${msg}`)
|
|
120
|
-
}
|
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
// ---------------------------------------------------------------------------
|
|
2
|
-
// ReturnPro data validation
|
|
3
|
-
// ---------------------------------------------------------------------------
|
|
4
|
-
//
|
|
5
|
-
// Pre-insert validators for stg_financials_raw and confirmed_income_statements.
|
|
6
|
-
// No external dependencies — pure TypeScript, no Supabase calls.
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
|
|
9
|
-
// --- Types ---
|
|
10
|
-
|
|
11
|
-
export interface ValidationResult {
|
|
12
|
-
valid: boolean
|
|
13
|
-
errors: string[]
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface BatchValidationResult {
|
|
17
|
-
totalRows: number
|
|
18
|
-
validRows: number
|
|
19
|
-
invalidRows: number
|
|
20
|
-
errors: Array<{ row: number; errors: string[] }>
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// --- Constants ---
|
|
24
|
-
|
|
25
|
-
/** Required fields for a stg_financials_raw row. */
|
|
26
|
-
const FINANCIAL_REQUIRED_FIELDS = ['client', 'program', 'account_code', 'amount', 'period'] as const
|
|
27
|
-
|
|
28
|
-
/** Required fields for a confirmed_income_statements row. */
|
|
29
|
-
const INCOME_REQUIRED_FIELDS = ['account_id', 'client_id', 'amount', 'period', 'source'] as const
|
|
30
|
-
|
|
31
|
-
/** Matches YYYY-MM format (e.g. "2025-04"). */
|
|
32
|
-
const PERIOD_REGEX = /^\d{4}-(0[1-9]|1[0-2])$/
|
|
33
|
-
|
|
34
|
-
// --- Helpers ---
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Check whether a value is present (not null, undefined, or empty string).
|
|
38
|
-
*/
|
|
39
|
-
function isPresent(value: unknown): boolean {
|
|
40
|
-
if (value === null || value === undefined) return false
|
|
41
|
-
if (typeof value === 'string' && value.trim() === '') return false
|
|
42
|
-
return true
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Check whether a value can be parsed as a finite number.
|
|
47
|
-
* Accepts numbers and numeric strings. Rejects NaN, Infinity, empty strings.
|
|
48
|
-
*/
|
|
49
|
-
function isNumeric(value: unknown): boolean {
|
|
50
|
-
if (typeof value === 'number') return isFinite(value)
|
|
51
|
-
if (typeof value === 'string') {
|
|
52
|
-
const trimmed = value.trim()
|
|
53
|
-
if (trimmed === '') return false
|
|
54
|
-
const parsed = Number(trimmed)
|
|
55
|
-
return isFinite(parsed)
|
|
56
|
-
}
|
|
57
|
-
return false
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// --- Core validators ---
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Validate a single row destined for `stg_financials_raw`.
|
|
64
|
-
*
|
|
65
|
-
* Checks:
|
|
66
|
-
* - Required fields: client, program, account_code, amount, period
|
|
67
|
-
* - `amount` is numeric (the DB column is TEXT, so non-numeric strings are a common bug)
|
|
68
|
-
* - `period` matches YYYY-MM format
|
|
69
|
-
*/
|
|
70
|
-
export function validateFinancialRow(row: Record<string, unknown>): ValidationResult {
|
|
71
|
-
const errors: string[] = []
|
|
72
|
-
|
|
73
|
-
// Check required fields
|
|
74
|
-
for (const field of FINANCIAL_REQUIRED_FIELDS) {
|
|
75
|
-
if (!isPresent(row[field])) {
|
|
76
|
-
errors.push(`Missing required field: ${field}`)
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Validate amount is numeric (only if present, to avoid duplicate error)
|
|
81
|
-
if (isPresent(row.amount) && !isNumeric(row.amount)) {
|
|
82
|
-
errors.push(`amount must be numeric, got: ${JSON.stringify(row.amount)}`)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Validate period format (only if present)
|
|
86
|
-
if (isPresent(row.period)) {
|
|
87
|
-
const period = String(row.period).trim()
|
|
88
|
-
if (!PERIOD_REGEX.test(period)) {
|
|
89
|
-
errors.push(`period must be YYYY-MM format (e.g. "2025-04"), got: "${period}"`)
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return { valid: errors.length === 0, errors }
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Validate a batch of rows destined for `stg_financials_raw`.
|
|
98
|
-
*
|
|
99
|
-
* Runs `validateFinancialRow` on each row and aggregates results.
|
|
100
|
-
*/
|
|
101
|
-
export function validateBatch(rows: Record<string, unknown>[]): BatchValidationResult {
|
|
102
|
-
const batchErrors: Array<{ row: number; errors: string[] }> = []
|
|
103
|
-
let validRows = 0
|
|
104
|
-
|
|
105
|
-
for (let i = 0; i < rows.length; i++) {
|
|
106
|
-
const result = validateFinancialRow(rows[i])
|
|
107
|
-
if (result.valid) {
|
|
108
|
-
validRows++
|
|
109
|
-
} else {
|
|
110
|
-
batchErrors.push({ row: i, errors: result.errors })
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return {
|
|
115
|
-
totalRows: rows.length,
|
|
116
|
-
validRows,
|
|
117
|
-
invalidRows: rows.length - validRows,
|
|
118
|
-
errors: batchErrors,
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Validate a single row destined for `confirmed_income_statements`.
|
|
124
|
-
*
|
|
125
|
-
* Checks:
|
|
126
|
-
* - Required fields: account_id, client_id, amount, period, source
|
|
127
|
-
* - `amount` is numeric
|
|
128
|
-
* - `period` matches YYYY-MM format
|
|
129
|
-
*/
|
|
130
|
-
export function validateIncomeStatementRow(row: Record<string, unknown>): ValidationResult {
|
|
131
|
-
const errors: string[] = []
|
|
132
|
-
|
|
133
|
-
// Check required fields
|
|
134
|
-
for (const field of INCOME_REQUIRED_FIELDS) {
|
|
135
|
-
if (!isPresent(row[field])) {
|
|
136
|
-
errors.push(`Missing required field: ${field}`)
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Validate amount is numeric (only if present)
|
|
141
|
-
if (isPresent(row.amount) && !isNumeric(row.amount)) {
|
|
142
|
-
errors.push(`amount must be numeric, got: ${JSON.stringify(row.amount)}`)
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Validate period format (only if present)
|
|
146
|
-
if (isPresent(row.period)) {
|
|
147
|
-
const period = String(row.period).trim()
|
|
148
|
-
if (!PERIOD_REGEX.test(period)) {
|
|
149
|
-
errors.push(`period must be YYYY-MM format (e.g. "2025-04"), got: "${period}"`)
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
return { valid: errors.length === 0, errors }
|
|
154
|
-
}
|
package/lib/social/meta.ts
DELETED
|
@@ -1,228 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Meta Graph API — Instagram Content Publishing
|
|
3
|
-
*
|
|
4
|
-
* Direct Instagram publishing via Meta's Content Publishing API.
|
|
5
|
-
* Replaces n8n webhook intermediary for IG posts.
|
|
6
|
-
*
|
|
7
|
-
* Functions:
|
|
8
|
-
* publishIgPhoto() — Publish a single image post to Instagram
|
|
9
|
-
* publishIgCarousel() — Publish a carousel (multi-image) post to Instagram
|
|
10
|
-
* getMetaConfig() — Read Meta credentials from env vars
|
|
11
|
-
* getMetaConfigForBrand() — Read brand-specific Meta credentials
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
// ── Types ────────────────────────────────────────────────────────────
|
|
15
|
-
|
|
16
|
-
export interface MetaConfig {
|
|
17
|
-
accessToken: string
|
|
18
|
-
igAccountId: string
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface PublishIgResult {
|
|
22
|
-
containerId: string
|
|
23
|
-
mediaId: string
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface PublishIgPhotoOptions {
|
|
27
|
-
imageUrl: string
|
|
28
|
-
caption: string
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface CarouselItem {
|
|
32
|
-
imageUrl: string
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface PublishIgCarouselOptions {
|
|
36
|
-
caption: string
|
|
37
|
-
items: CarouselItem[]
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export class MetaApiError extends Error {
|
|
41
|
-
constructor(
|
|
42
|
-
message: string,
|
|
43
|
-
public status: number,
|
|
44
|
-
public metaError?: { message: string; type?: string; code?: number },
|
|
45
|
-
) {
|
|
46
|
-
super(message)
|
|
47
|
-
this.name = 'MetaApiError'
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// ── Config ───────────────────────────────────────────────────────────
|
|
52
|
-
|
|
53
|
-
const GRAPH_API_BASE = 'https://graph.facebook.com/v21.0'
|
|
54
|
-
|
|
55
|
-
// Injectable fetch for testing
|
|
56
|
-
let _fetch: typeof globalThis.fetch = globalThis.fetch
|
|
57
|
-
|
|
58
|
-
export function setFetchForTests(fn: typeof globalThis.fetch): void {
|
|
59
|
-
_fetch = fn
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function resetFetchForTests(): void {
|
|
63
|
-
_fetch = globalThis.fetch
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// ── Internal helpers ─────────────────────────────────────────────────
|
|
67
|
-
|
|
68
|
-
async function graphPost(
|
|
69
|
-
path: string,
|
|
70
|
-
body: Record<string, unknown>,
|
|
71
|
-
): Promise<{ id: string }> {
|
|
72
|
-
const res = await _fetch(`${GRAPH_API_BASE}${path}`, {
|
|
73
|
-
method: 'POST',
|
|
74
|
-
headers: { 'Content-Type': 'application/json' },
|
|
75
|
-
body: JSON.stringify(body),
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
const data = await res.json() as Record<string, unknown>
|
|
79
|
-
|
|
80
|
-
if (!res.ok) {
|
|
81
|
-
const err = data.error as { message?: string; type?: string; code?: number } | undefined
|
|
82
|
-
throw new MetaApiError(
|
|
83
|
-
err?.message ?? `Meta API ${res.status}: ${res.statusText}`,
|
|
84
|
-
res.status,
|
|
85
|
-
err ? { message: err.message ?? '', type: err.type, code: err.code } : undefined,
|
|
86
|
-
)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
return data as { id: string }
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// ── Config readers ───────────────────────────────────────────────────
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Read Meta API credentials from environment variables.
|
|
96
|
-
* Requires: META_ACCESS_TOKEN, META_IG_ACCOUNT_ID
|
|
97
|
-
*/
|
|
98
|
-
export function getMetaConfig(): MetaConfig {
|
|
99
|
-
const accessToken = process.env.META_ACCESS_TOKEN
|
|
100
|
-
const igAccountId = process.env.META_IG_ACCOUNT_ID
|
|
101
|
-
if (!accessToken) {
|
|
102
|
-
throw new Error(
|
|
103
|
-
'Missing env var: META_ACCESS_TOKEN\n' +
|
|
104
|
-
'Get a long-lived page access token from Meta Business Suite:\n' +
|
|
105
|
-
' https://business.facebook.com/settings/system-users',
|
|
106
|
-
)
|
|
107
|
-
}
|
|
108
|
-
if (!igAccountId) {
|
|
109
|
-
throw new Error(
|
|
110
|
-
'Missing env var: META_IG_ACCOUNT_ID\n' +
|
|
111
|
-
'Find your IG Business account ID via Graph API Explorer:\n' +
|
|
112
|
-
' GET /me/accounts → page_id → GET /{page_id}?fields=instagram_business_account',
|
|
113
|
-
)
|
|
114
|
-
}
|
|
115
|
-
return { accessToken, igAccountId }
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Read Meta API credentials for a specific brand.
|
|
120
|
-
* Looks for META_IG_ACCOUNT_ID_{BRAND} first, falls back to META_IG_ACCOUNT_ID.
|
|
121
|
-
* Brand key is normalized: hyphens become underscores (CRE-11TRUST → CRE_11TRUST).
|
|
122
|
-
*/
|
|
123
|
-
export function getMetaConfigForBrand(brand: string): MetaConfig {
|
|
124
|
-
const accessToken = process.env.META_ACCESS_TOKEN
|
|
125
|
-
if (!accessToken) {
|
|
126
|
-
throw new Error('Missing env var: META_ACCESS_TOKEN')
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const envKey = `META_IG_ACCOUNT_ID_${brand.replace(/-/g, '_')}`
|
|
130
|
-
const igAccountId = process.env[envKey] ?? process.env.META_IG_ACCOUNT_ID
|
|
131
|
-
|
|
132
|
-
if (!igAccountId) {
|
|
133
|
-
throw new Error(`Missing env var: ${envKey} or META_IG_ACCOUNT_ID`)
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return { accessToken, igAccountId }
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// ── Publishing ───────────────────────────────────────────────────────
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Publish a single photo to Instagram.
|
|
143
|
-
*
|
|
144
|
-
* Two-step process per Meta Content Publishing API:
|
|
145
|
-
* 1. Create media container with image_url + caption
|
|
146
|
-
* 2. Publish the container
|
|
147
|
-
*
|
|
148
|
-
* @example
|
|
149
|
-
* const result = await publishIgPhoto(config, {
|
|
150
|
-
* imageUrl: 'https://cdn.example.com/photo.jpg',
|
|
151
|
-
* caption: 'Check out our latest listing! #realestate',
|
|
152
|
-
* })
|
|
153
|
-
* console.log(`Published: ${result.mediaId}`)
|
|
154
|
-
*/
|
|
155
|
-
export async function publishIgPhoto(
|
|
156
|
-
config: MetaConfig,
|
|
157
|
-
opts: PublishIgPhotoOptions,
|
|
158
|
-
): Promise<PublishIgResult> {
|
|
159
|
-
// Step 1: Create media container
|
|
160
|
-
const container = await graphPost(`/${config.igAccountId}/media`, {
|
|
161
|
-
image_url: opts.imageUrl,
|
|
162
|
-
caption: opts.caption,
|
|
163
|
-
access_token: config.accessToken,
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
// Step 2: Publish the container
|
|
167
|
-
const published = await graphPost(`/${config.igAccountId}/media_publish`, {
|
|
168
|
-
creation_id: container.id,
|
|
169
|
-
access_token: config.accessToken,
|
|
170
|
-
})
|
|
171
|
-
|
|
172
|
-
return {
|
|
173
|
-
containerId: container.id,
|
|
174
|
-
mediaId: published.id,
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Publish a carousel (multi-image) post to Instagram.
|
|
180
|
-
*
|
|
181
|
-
* Three-step process:
|
|
182
|
-
* 1. Create individual item containers (is_carousel_item=true)
|
|
183
|
-
* 2. Create carousel container referencing all item IDs
|
|
184
|
-
* 3. Publish the carousel container
|
|
185
|
-
*
|
|
186
|
-
* @example
|
|
187
|
-
* const result = await publishIgCarousel(config, {
|
|
188
|
-
* caption: 'Property tour highlights',
|
|
189
|
-
* items: [
|
|
190
|
-
* { imageUrl: 'https://cdn.example.com/1.jpg' },
|
|
191
|
-
* { imageUrl: 'https://cdn.example.com/2.jpg' },
|
|
192
|
-
* ],
|
|
193
|
-
* })
|
|
194
|
-
*/
|
|
195
|
-
export async function publishIgCarousel(
|
|
196
|
-
config: MetaConfig,
|
|
197
|
-
opts: PublishIgCarouselOptions,
|
|
198
|
-
): Promise<PublishIgResult> {
|
|
199
|
-
// Step 1: Create individual item containers
|
|
200
|
-
const itemIds: string[] = []
|
|
201
|
-
for (const item of opts.items) {
|
|
202
|
-
const container = await graphPost(`/${config.igAccountId}/media`, {
|
|
203
|
-
image_url: item.imageUrl,
|
|
204
|
-
is_carousel_item: true,
|
|
205
|
-
access_token: config.accessToken,
|
|
206
|
-
})
|
|
207
|
-
itemIds.push(container.id)
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Step 2: Create carousel container
|
|
211
|
-
const carousel = await graphPost(`/${config.igAccountId}/media`, {
|
|
212
|
-
media_type: 'CAROUSEL',
|
|
213
|
-
children: itemIds,
|
|
214
|
-
caption: opts.caption,
|
|
215
|
-
access_token: config.accessToken,
|
|
216
|
-
})
|
|
217
|
-
|
|
218
|
-
// Step 3: Publish
|
|
219
|
-
const published = await graphPost(`/${config.igAccountId}/media_publish`, {
|
|
220
|
-
creation_id: carousel.id,
|
|
221
|
-
access_token: config.accessToken,
|
|
222
|
-
})
|
|
223
|
-
|
|
224
|
-
return {
|
|
225
|
-
containerId: carousel.id,
|
|
226
|
-
mediaId: published.id,
|
|
227
|
-
}
|
|
228
|
-
}
|