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
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: upload-netsuite
|
|
3
|
+
description: Upload NetSuite XLSM or CSV financial data into ReturnPro staging tables
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Purpose
|
|
7
|
+
Uploads NetSuite financial exports (XLSM macro-enabled workbooks or CSV files) into `stg_financials_raw`. This is the primary data pipeline for ReturnPro FP&A staging. Handles both single-sheet CSV files and multi-sheet XLSM workbooks (auto-detects format). Supports Wes-style multi-sheet workbooks where each month tab contains that month's data.
|
|
8
|
+
|
|
9
|
+
## Inputs
|
|
10
|
+
- **file** (required): Absolute path to the NetSuite XLSM or CSV file on disk
|
|
11
|
+
- **month** (optional): Target month as YYYY-MM. Required for CSV files. For XLSM with monthly tabs, auto-detected from sheet names.
|
|
12
|
+
- **dry-run** (optional): Parse and validate without writing to Supabase.
|
|
13
|
+
|
|
14
|
+
## Steps
|
|
15
|
+
1. Call `lib/returnpro/upload-netsuite.ts::uploadNetsuite(file, month?, options?)` to orchestrate the upload
|
|
16
|
+
2. Detect file format by extension (.xlsm, .xlsx, .csv)
|
|
17
|
+
3. For XLSM/XLSX: check for multi-sheet layout using `hasMonthlySheets()` — if 3+ month-named sheets exist, read per-month tabs (NOT Summary)
|
|
18
|
+
4. For CSV: read single file, require `--month` parameter
|
|
19
|
+
5. Parse rows into staging format: `account_code`, `account_name`, `amount` (as TEXT), `period` (YYYY-MM), `source_file`
|
|
20
|
+
6. Resolve account codes against `dim_account` for validation
|
|
21
|
+
7. Batch-upsert into `stg_financials_raw` (keyed on account_code + period + master_program_id)
|
|
22
|
+
8. Log execution via `lib/kanban.ts::logSkillExecution()`
|
|
23
|
+
|
|
24
|
+
## Output
|
|
25
|
+
```
|
|
26
|
+
Format: NetSuite XLSM (multi-sheet: Jan, Feb, Mar)
|
|
27
|
+
Parsed 3 months: 2026-01 (91 rows), 2026-02 (89 rows), 2026-03 (93 rows)
|
|
28
|
+
Total inserted: 273 | Updated: 0 | Skipped: 0
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## CLI Usage
|
|
32
|
+
```bash
|
|
33
|
+
# XLSM with auto-detected monthly tabs
|
|
34
|
+
optimal upload-netsuite --file ~/Downloads/returnpro-data/Solution7-Q1-2026.xlsm
|
|
35
|
+
|
|
36
|
+
# CSV with explicit month
|
|
37
|
+
optimal upload-netsuite --file ~/Downloads/returnpro-data/netsuite-jan-2026.csv --month 2026-01
|
|
38
|
+
|
|
39
|
+
# Dry run to preview
|
|
40
|
+
optimal upload-netsuite --file ~/Downloads/returnpro-data/Solution7-Q1-2026.xlsm --dry-run
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Environment
|
|
44
|
+
Requires: `RETURNPRO_SUPABASE_URL`, `RETURNPRO_SUPABASE_SERVICE_KEY`
|
|
45
|
+
|
|
46
|
+
## Tables Touched
|
|
47
|
+
- `stg_financials_raw` — upsert parsed rows (amount stored as TEXT)
|
|
48
|
+
- `dim_account` — validate account codes
|
|
49
|
+
|
|
50
|
+
## Gotchas
|
|
51
|
+
- **amount is TEXT**: The `stg_financials_raw.amount` column is TEXT, not NUMERIC. Always CAST before numeric comparisons.
|
|
52
|
+
- **Multi-sheet detection**: `hasMonthlySheets()` triggers when 3+ sheets have month-like names. If present, reads individual month tabs and ignores Summary sheet.
|
|
53
|
+
- **Never run SQL manually**: Use migration files + `supabase db push --linked` for schema changes.
|
|
54
|
+
|
|
55
|
+
## Status
|
|
56
|
+
Implementation status: Not yet implemented. Spec only. Lib function `lib/returnpro/upload-netsuite.ts` to be extracted from dashboard-returnpro's `/api/staging/upload` route.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: upload-r1
|
|
3
|
+
description: Upload R1 XLSX files parsed with WASM/calamine, aggregate by program, and load into ReturnPro staging
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Purpose
|
|
7
|
+
Uploads R1 reverse-logistics XLSX files into ReturnPro's financial staging tables. The R1 export is parsed using WASM-based calamine (fast XLSX parser without ExcelJS overhead), rows are aggregated by master program, and results are upserted into `stg_financials_raw`. This is one of the primary data ingestion paths for ReturnPro FP&A.
|
|
8
|
+
|
|
9
|
+
## Inputs
|
|
10
|
+
- **file** (required): Absolute path to the R1 XLSX file on disk
|
|
11
|
+
- **month** (required): Target month as YYYY-MM (e.g., `2026-01`). Used for the staging period column.
|
|
12
|
+
- **dry-run** (optional): Parse and aggregate without writing to Supabase. Useful for previewing row counts.
|
|
13
|
+
|
|
14
|
+
## Steps
|
|
15
|
+
1. Call `lib/returnpro/upload-r1.ts::uploadR1(file, month, options?)` to orchestrate the upload
|
|
16
|
+
2. Read the XLSX file with WASM/calamine parser (faster than ExcelJS for large files)
|
|
17
|
+
3. Normalize column headers — map R1-specific column names to standard staging fields (account_code, amount, description)
|
|
18
|
+
4. Aggregate rows by `master_program_id` + `account_code` within the target month
|
|
19
|
+
5. Resolve program names to `dim_master_program.id` via fuzzy match
|
|
20
|
+
6. Upsert aggregated rows into `stg_financials_raw` (keyed on account_code + month + master_program_id)
|
|
21
|
+
7. Log execution via `lib/kanban.ts::logSkillExecution()`
|
|
22
|
+
|
|
23
|
+
## Output
|
|
24
|
+
```
|
|
25
|
+
Parsed R1 XLSX: 1,842 rows across 47 programs
|
|
26
|
+
Aggregated to 312 staging rows for 2026-01
|
|
27
|
+
Inserted: 298 | Updated: 14 | Skipped: 0
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## CLI Usage
|
|
31
|
+
```bash
|
|
32
|
+
optimal upload-r1 --file ~/Downloads/returnpro-data/R1-January-2026.xlsx --month 2026-01
|
|
33
|
+
optimal upload-r1 --file ~/Downloads/returnpro-data/R1-January-2026.xlsx --month 2026-01 --dry-run
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Environment
|
|
37
|
+
Requires: `RETURNPRO_SUPABASE_URL`, `RETURNPRO_SUPABASE_SERVICE_KEY`
|
|
38
|
+
|
|
39
|
+
## Tables Touched
|
|
40
|
+
- `stg_financials_raw` — upsert aggregated rows (note: `amount` column is TEXT, not NUMERIC)
|
|
41
|
+
- `dim_master_program` — lookup for program name resolution
|
|
42
|
+
- `dim_account` — lookup for account code validation
|
|
43
|
+
|
|
44
|
+
## Status
|
|
45
|
+
Implementation status: Not yet implemented. Spec only. Lib function `lib/returnpro/upload-r1.ts` to be extracted from dashboard-returnpro's `/api/r1/` routes.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
v2.75.0
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
-- Create table for storing agent OpenClaw configs
|
|
2
|
+
CREATE TABLE IF NOT EXISTS agent_configs (
|
|
3
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
4
|
+
agent_name TEXT NOT NULL UNIQUE,
|
|
5
|
+
config_json JSONB NOT NULL,
|
|
6
|
+
version TEXT NOT NULL,
|
|
7
|
+
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
8
|
+
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
-- Index for faster lookups
|
|
12
|
+
CREATE INDEX IF NOT EXISTS idx_agent_configs_name ON agent_configs(agent_name);
|
|
13
|
+
|
|
14
|
+
-- Enable RLS
|
|
15
|
+
ALTER TABLE agent_configs ENABLE ROW LEVEL SECURITY;
|
|
16
|
+
|
|
17
|
+
-- Allow service role full access
|
|
18
|
+
CREATE POLICY "Service role full access" ON agent_configs
|
|
19
|
+
FOR ALL
|
|
20
|
+
TO service_role
|
|
21
|
+
USING (true)
|
|
22
|
+
WITH CHECK (true);
|
|
23
|
+
|
|
24
|
+
-- Add updated_at trigger
|
|
25
|
+
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
|
26
|
+
RETURNS TRIGGER AS $$
|
|
27
|
+
BEGIN
|
|
28
|
+
NEW.updated_at = NOW();
|
|
29
|
+
RETURN NEW;
|
|
30
|
+
END;
|
|
31
|
+
$$ LANGUAGE plpgsql;
|
|
32
|
+
|
|
33
|
+
CREATE TRIGGER update_agent_configs_updated_at
|
|
34
|
+
BEFORE UPDATE ON agent_configs
|
|
35
|
+
FOR EACH ROW
|
|
36
|
+
EXECUTE FUNCTION update_updated_at_column();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
-- create shared config registry for optimal-cli profile sync
|
|
2
|
+
create table if not exists public.cli_config_registry (
|
|
3
|
+
id uuid primary key default gen_random_uuid(),
|
|
4
|
+
owner text not null,
|
|
5
|
+
profile text not null default 'default',
|
|
6
|
+
config_version text not null,
|
|
7
|
+
payload jsonb not null,
|
|
8
|
+
payload_hash text not null,
|
|
9
|
+
source text not null default 'optimal-cli',
|
|
10
|
+
updated_by text,
|
|
11
|
+
updated_at timestamptz not null default now(),
|
|
12
|
+
created_at timestamptz not null default now(),
|
|
13
|
+
unique (owner, profile)
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
create index if not exists idx_cli_config_registry_owner_profile
|
|
17
|
+
on public.cli_config_registry (owner, profile);
|
|
18
|
+
|
|
19
|
+
create index if not exists idx_cli_config_registry_updated_at
|
|
20
|
+
on public.cli_config_registry (updated_at desc);
|
|
21
|
+
|
|
22
|
+
-- note: rls policies intentionally deferred until auth model is finalized.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
-- Kanban tables for 3-way sync: supabase + obsidian + cli
|
|
2
|
+
|
|
3
|
+
-- Projects table
|
|
4
|
+
CREATE TABLE IF NOT EXISTS cli_projects (
|
|
5
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
6
|
+
slug TEXT UNIQUE NOT NULL,
|
|
7
|
+
name TEXT NOT NULL,
|
|
8
|
+
description TEXT,
|
|
9
|
+
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'archived', 'on_hold')),
|
|
10
|
+
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
11
|
+
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
-- Tasks table
|
|
15
|
+
CREATE TABLE IF NOT EXISTS cli_tasks (
|
|
16
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
17
|
+
project_id UUID REFERENCES cli_projects(id) ON DELETE CASCADE,
|
|
18
|
+
task_id TEXT NOT NULL, -- from obsidian filename, e.g., "task__optimal-cli-shared-config-registry__b7c1d2e3"
|
|
19
|
+
title TEXT NOT NULL,
|
|
20
|
+
description TEXT,
|
|
21
|
+
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'done', 'cancelled')),
|
|
22
|
+
priority INTEGER DEFAULT 3 CHECK (priority BETWEEN 1 AND 4),
|
|
23
|
+
owner TEXT, -- agent or person responsible
|
|
24
|
+
assignee TEXT,
|
|
25
|
+
tags JSONB DEFAULT '[]',
|
|
26
|
+
source TEXT, -- e.g., "chat:carlos:2026-03-04"
|
|
27
|
+
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
28
|
+
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
29
|
+
completed_at TIMESTAMPTZ,
|
|
30
|
+
completed_by TEXT,
|
|
31
|
+
metadata JSONB DEFAULT '{}',
|
|
32
|
+
UNIQUE(project_id, task_id)
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
-- Sync log for tracking changes
|
|
36
|
+
CREATE TABLE IF NOT EXISTS cli_sync_log (
|
|
37
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
38
|
+
entity_type TEXT NOT NULL, -- 'project' or 'task'
|
|
39
|
+
entity_id UUID NOT NULL,
|
|
40
|
+
action TEXT NOT NULL, -- 'create', 'update', 'delete'
|
|
41
|
+
source TEXT NOT NULL, -- 'cli', 'obsidian', 'manual'
|
|
42
|
+
synced_at TIMESTAMPTZ DEFAULT NOW(),
|
|
43
|
+
payload JSONB
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
-- Indexes
|
|
47
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_project ON cli_tasks(project_id);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_status ON cli_tasks(status);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_owner ON cli_tasks(owner);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON cli_tasks(assignee);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_sync_log_entity ON cli_sync_log(entity_type, entity_id);
|
|
52
|
+
|
|
53
|
+
-- Enable RLS
|
|
54
|
+
ALTER TABLE cli_projects ENABLE ROW LEVEL SECURITY;
|
|
55
|
+
ALTER TABLE cli_tasks ENABLE ROW LEVEL SECURITY;
|
|
56
|
+
ALTER TABLE cli_sync_log ENABLE ROW LEVEL SECURITY;
|
|
57
|
+
|
|
58
|
+
-- RLS policies (service role can do everything)
|
|
59
|
+
CREATE POLICY "service_role_full_access_projects" ON cli_projects FOR ALL USING (true) WITH CHECK (true);
|
|
60
|
+
CREATE POLICY "service_role_full_access_tasks" ON cli_tasks FOR ALL USING (true) WITH CHECK (true);
|
|
61
|
+
CREATE POLICY "service_role_full_access_sync_log" ON cli_sync_log FOR ALL USING (true) WITH CHECK (true);
|
|
62
|
+
|
|
63
|
+
-- Function to update updated_at
|
|
64
|
+
CREATE OR REPLACE FUNCTION update_updated_at()
|
|
65
|
+
RETURNS TRIGGER AS $$
|
|
66
|
+
BEGIN
|
|
67
|
+
NEW.updated_at = NOW();
|
|
68
|
+
RETURN NEW;
|
|
69
|
+
END;
|
|
70
|
+
$$ LANGUAGE plpgsql;
|
|
71
|
+
|
|
72
|
+
-- Triggers for updated_at
|
|
73
|
+
CREATE TRIGGER update_projects_updated_at BEFORE UPDATE ON cli_projects
|
|
74
|
+
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
|
75
|
+
|
|
76
|
+
CREATE TRIGGER update_tasks_updated_at BEFORE UPDATE ON cli_tasks
|
|
77
|
+
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
|
78
|
+
|
|
79
|
+
-- Insert default project for optimal tasks
|
|
80
|
+
INSERT INTO cli_projects (slug, name, description)
|
|
81
|
+
VALUES ('optimal-tasks', 'Optimal Tasks', 'Main task tracking for optimal agents')
|
|
82
|
+
ON CONFLICT (slug) DO NOTHING;
|
|
83
|
+
|
|
84
|
+
-- Function to log sync
|
|
85
|
+
CREATE OR REPLACE FUNCTION log_sync(
|
|
86
|
+
p_entity_type TEXT,
|
|
87
|
+
p_entity_id UUID,
|
|
88
|
+
p_action TEXT,
|
|
89
|
+
p_source TEXT,
|
|
90
|
+
p_payload JSONB DEFAULT '{}'::JSONB
|
|
91
|
+
)
|
|
92
|
+
RETURNS VOID AS $$
|
|
93
|
+
BEGIN
|
|
94
|
+
INSERT INTO cli_sync_log (entity_type, entity_id, action, source, payload)
|
|
95
|
+
VALUES (p_entity_type, p_entity_id, p_action, p_source, p_payload);
|
|
96
|
+
END;
|
|
97
|
+
$$ LANGUAGE plpgsql;
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command-level smoke tests for optimal config commands.
|
|
3
|
+
* These test the CLI surface directly via subprocess invocation.
|
|
4
|
+
*/
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises'
|
|
8
|
+
import { tmpdir } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { spawn } from 'node:child_process'
|
|
11
|
+
import { env } from 'node:process'
|
|
12
|
+
|
|
13
|
+
// Run from the actual project directory, but with a custom HOME for config isolation
|
|
14
|
+
const PROJECT_DIR = '/home/oracle/.openclaw/workspace/optimal-cli'
|
|
15
|
+
|
|
16
|
+
function runOptimalCli(args: string[], extraEnv: Record<string, string> = {}): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const child = spawn('npx', ['tsx', 'bin/optimal.ts', ...args], {
|
|
19
|
+
cwd: PROJECT_DIR,
|
|
20
|
+
env: { ...env, ...extraEnv },
|
|
21
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
let stdout = ''
|
|
25
|
+
let stderr = ''
|
|
26
|
+
|
|
27
|
+
child.stdout?.on('data', (data) => { stdout += data.toString() })
|
|
28
|
+
child.stderr?.on('data', (data) => { stderr += data.toString() })
|
|
29
|
+
child.on('close', (code) => {
|
|
30
|
+
resolve({ stdout, stderr, code: code ?? 0 })
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test('config init creates local config file with defaults', async () => {
|
|
36
|
+
const configDir = await mkdtemp(join(tmpdir(), 'optimal-cli-smoke-'))
|
|
37
|
+
try {
|
|
38
|
+
const result = await runOptimalCli(
|
|
39
|
+
['config', 'init', '--owner', 'test-smoke', '--profile', 'testprof', '--brand', 'CRE-11TRUST', '--timezone', 'America/New_York'],
|
|
40
|
+
{ OPTIMAL_CONFIG_DIR: configDir, OPTIMAL_CONFIG_OWNER: 'test-smoke' }
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
assert.equal(result.code, 0, `config init failed: ${result.stderr}`)
|
|
44
|
+
assert.match(result.stdout, /Initialized config/)
|
|
45
|
+
|
|
46
|
+
// Verify config file was created
|
|
47
|
+
const configPath = join(configDir, 'optimal.config.json')
|
|
48
|
+
const content = await readFile(configPath, 'utf-8')
|
|
49
|
+
const config = JSON.parse(content)
|
|
50
|
+
|
|
51
|
+
assert.equal(config.version, '1.0.0')
|
|
52
|
+
assert.equal(config.profile.name, 'testprof')
|
|
53
|
+
assert.equal(config.profile.owner, 'test-smoke')
|
|
54
|
+
assert.equal(config.defaults.brand, 'CRE-11TRUST')
|
|
55
|
+
assert.equal(config.defaults.timezone, 'America/New_York')
|
|
56
|
+
|
|
57
|
+
// Verify history was created
|
|
58
|
+
const historyPath = join(configDir, 'config-history.log')
|
|
59
|
+
const history = await readFile(historyPath, 'utf-8')
|
|
60
|
+
assert.match(history, /init profile=testprof/)
|
|
61
|
+
} finally {
|
|
62
|
+
await rm(configDir, { recursive: true, force: true })
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('config doctor reports health for existing config', async () => {
|
|
67
|
+
const configDir = await mkdtemp(join(tmpdir(), 'optimal-cli-smoke-'))
|
|
68
|
+
try {
|
|
69
|
+
// First create a config
|
|
70
|
+
await runOptimalCli(
|
|
71
|
+
['config', 'init', '--owner', 'test-doctor', '--profile', 'default', '--brand', 'LIFEINSUR', '--timezone', 'America/Los_Angeles'],
|
|
72
|
+
{ OPTIMAL_CONFIG_DIR: configDir }
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
// Now run doctor
|
|
76
|
+
const result = await runOptimalCli(
|
|
77
|
+
['config', 'doctor'],
|
|
78
|
+
{ OPTIMAL_CONFIG_DIR: configDir }
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
assert.equal(result.code, 0, `config doctor failed: ${result.stderr}`)
|
|
82
|
+
assert.match(result.stdout, /config: ok/)
|
|
83
|
+
assert.match(result.stdout, /profile: default/)
|
|
84
|
+
assert.match(result.stdout, /owner: test-doctor/)
|
|
85
|
+
assert.match(result.stdout, /version: 1.0.0/)
|
|
86
|
+
assert.match(result.stdout, /hash:/)
|
|
87
|
+
} finally {
|
|
88
|
+
await rm(configDir, { recursive: true, force: true })
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('config export writes JSON to specified path', async () => {
|
|
93
|
+
const configDir = await mkdtemp(join(tmpdir(), 'optimal-cli-smoke-'))
|
|
94
|
+
try {
|
|
95
|
+
// First create a config
|
|
96
|
+
await runOptimalCli(
|
|
97
|
+
['config', 'init', '--owner', 'test-export', '--profile', 'default'],
|
|
98
|
+
{ OPTIMAL_CONFIG_DIR: configDir }
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
// Export to a temp path
|
|
102
|
+
const exportPath = join(configDir, 'exported-config.json')
|
|
103
|
+
const result = await runOptimalCli(
|
|
104
|
+
['config', 'export', '--out', exportPath],
|
|
105
|
+
{ OPTIMAL_CONFIG_DIR: configDir }
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
assert.equal(result.code, 0, `config export failed: ${result.stderr}`)
|
|
109
|
+
assert.match(result.stdout, /Exported config/)
|
|
110
|
+
|
|
111
|
+
// Verify exported file
|
|
112
|
+
const content = await readFile(exportPath, 'utf-8')
|
|
113
|
+
const config = JSON.parse(content)
|
|
114
|
+
assert.equal(config.profile.owner, 'test-export')
|
|
115
|
+
} finally {
|
|
116
|
+
await rm(configDir, { recursive: true, force: true })
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('config import reads JSON from specified path', async () => {
|
|
121
|
+
const configDir = await mkdtemp(join(tmpdir(), 'optimal-cli-smoke-'))
|
|
122
|
+
try {
|
|
123
|
+
// Create a config file to import
|
|
124
|
+
const importPath = join(configDir, 'import-config.json')
|
|
125
|
+
const configToImport = {
|
|
126
|
+
version: '1.0.0',
|
|
127
|
+
profile: { name: 'imported', owner: 'test-import', updated_at: '2026-03-06T15:00:00.000Z' },
|
|
128
|
+
providers: { supabase: { project_ref: 'test', url: 'https://test.supabase.co', anon_key_present: true }, strapi: { base_url: 'https://test.strapi.com', token_present: true } },
|
|
129
|
+
defaults: { brand: 'CRE-11TRUST', timezone: 'America/Chicago' },
|
|
130
|
+
features: { cms: true, tasks: false, deploy: false },
|
|
131
|
+
}
|
|
132
|
+
await writeFile(importPath, JSON.stringify(configToImport, null, 2))
|
|
133
|
+
|
|
134
|
+
// Import it
|
|
135
|
+
const result = await runOptimalCli(
|
|
136
|
+
['config', 'import', '--in', importPath],
|
|
137
|
+
{ OPTIMAL_CONFIG_DIR: configDir }
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
assert.equal(result.code, 0, `config import failed: ${result.stderr}`)
|
|
141
|
+
assert.match(result.stdout, /Imported config/)
|
|
142
|
+
|
|
143
|
+
// Verify it was imported
|
|
144
|
+
const configPath = join(configDir, 'optimal.config.json')
|
|
145
|
+
const content = await readFile(configPath, 'utf-8')
|
|
146
|
+
const config = JSON.parse(content)
|
|
147
|
+
assert.equal(config.profile.name, 'imported')
|
|
148
|
+
assert.equal(config.profile.owner, 'test-import')
|
|
149
|
+
} finally {
|
|
150
|
+
await rm(configDir, { recursive: true, force: true })
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('config sync pull --dry-run shows what would be pulled', async () => {
|
|
155
|
+
const configDir = await mkdtemp(join(tmpdir(), 'optimal-cli-smoke-'))
|
|
156
|
+
try {
|
|
157
|
+
// Create a config first
|
|
158
|
+
await runOptimalCli(
|
|
159
|
+
['config', 'init', '--owner', 'test-pull', '--profile', 'default'],
|
|
160
|
+
{ OPTIMAL_CONFIG_DIR: configDir }
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
// Run dry-run pull
|
|
164
|
+
const result = await runOptimalCli(
|
|
165
|
+
['config', 'sync', 'pull', '--profile', 'default', '--dry-run'],
|
|
166
|
+
{ OPTIMAL_CONFIG_DIR: configDir }
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
assert.equal(result.code, 0, `config sync pull --dry-run failed: ${result.stderr}`)
|
|
170
|
+
assert.match(result.stdout, /\[dry-run\]/)
|
|
171
|
+
assert.match(result.stdout, /Would pull profile/)
|
|
172
|
+
assert.match(result.stdout, /test-pull/)
|
|
173
|
+
} finally {
|
|
174
|
+
await rm(configDir, { recursive: true, force: true })
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('config sync push --dry-run shows what would be pushed', async () => {
|
|
179
|
+
const configDir = await mkdtemp(join(tmpdir(), 'optimal-cli-smoke-'))
|
|
180
|
+
try {
|
|
181
|
+
// Create a config first
|
|
182
|
+
await runOptimalCli(
|
|
183
|
+
['config', 'init', '--owner', 'test-push', '--profile', 'default'],
|
|
184
|
+
{ OPTIMAL_CONFIG_DIR: configDir }
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
// Run dry-run push
|
|
188
|
+
const result = await runOptimalCli(
|
|
189
|
+
['config', 'sync', 'push', '--agent', 'test-push-agent', '--profile', 'default', '--dry-run'],
|
|
190
|
+
{ OPTIMAL_CONFIG_DIR: configDir }
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
assert.equal(result.code, 0, `config sync push --dry-run failed: ${result.stderr}`)
|
|
194
|
+
assert.match(result.stdout, /\[dry-run\]/)
|
|
195
|
+
assert.match(result.stdout, /Would push/)
|
|
196
|
+
assert.match(result.stdout, /test-push-agent/)
|
|
197
|
+
assert.match(result.stdout, /hash:/)
|
|
198
|
+
} finally {
|
|
199
|
+
await rm(configDir, { recursive: true, force: true })
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('config sync push --force --dry-run shows force flag', async () => {
|
|
204
|
+
const configDir = await mkdtemp(join(tmpdir(), 'optimal-cli-smoke-'))
|
|
205
|
+
try {
|
|
206
|
+
// Create a config first
|
|
207
|
+
await runOptimalCli(
|
|
208
|
+
['config', 'init', '--owner', 'test-force', '--profile', 'default'],
|
|
209
|
+
{ OPTIMAL_CONFIG_DIR: configDir }
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
// Run dry-run push with force
|
|
213
|
+
const result = await runOptimalCli(
|
|
214
|
+
['config', 'sync', 'push', '--agent', 'test-force-agent', '--profile', 'default', '--force', '--dry-run'],
|
|
215
|
+
{ OPTIMAL_CONFIG_DIR: configDir }
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
assert.equal(result.code, 0, `config sync push --force --dry-run failed: ${result.stderr}`)
|
|
219
|
+
assert.match(result.stdout, /Force: true/)
|
|
220
|
+
} finally {
|
|
221
|
+
await rm(configDir, { recursive: true, force: true })
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
// Error case tests
|
|
226
|
+
|
|
227
|
+
test('config doctor fails gracefully when no config exists', async () => {
|
|
228
|
+
const configDir = await mkdtemp(join(tmpdir(), 'optimal-cli-smoke-'))
|
|
229
|
+
try {
|
|
230
|
+
const result = await runOptimalCli(
|
|
231
|
+
['config', 'doctor'],
|
|
232
|
+
{ OPTIMAL_CONFIG_DIR: configDir }
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
assert.equal(result.code, 1, `Expected failure code, got: ${result.code}`)
|
|
236
|
+
// Error message goes to stdout (console.log), not stderr
|
|
237
|
+
assert.match(result.stdout, /No local config found/)
|
|
238
|
+
} finally {
|
|
239
|
+
await rm(configDir, { recursive: true, force: true })
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('config export fails when no config exists', async () => {
|
|
244
|
+
const configDir = await mkdtemp(join(tmpdir(), 'optimal-cli-smoke-'))
|
|
245
|
+
try {
|
|
246
|
+
const result = await runOptimalCli(
|
|
247
|
+
['config', 'export', '--out', join(configDir, 'out.json')],
|
|
248
|
+
{ OPTIMAL_CONFIG_DIR: configDir }
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
assert.equal(result.code, 1, `Expected failure code, got: ${result.code}`)
|
|
252
|
+
assert.match(result.stderr, /No local config found/)
|
|
253
|
+
} finally {
|
|
254
|
+
await rm(configDir, { recursive: true, force: true })
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('config sync push --dry-run fails when no config exists', async () => {
|
|
259
|
+
const configDir = await mkdtemp(join(tmpdir(), 'optimal-cli-smoke-'))
|
|
260
|
+
try {
|
|
261
|
+
const result = await runOptimalCli(
|
|
262
|
+
['config', 'sync', 'push', '--agent', 'test', '--dry-run'],
|
|
263
|
+
{ OPTIMAL_CONFIG_DIR: configDir }
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
assert.equal(result.code, 1, `Expected failure code, got: ${result.code}`)
|
|
267
|
+
assert.match(result.stderr, /No local config/)
|
|
268
|
+
} finally {
|
|
269
|
+
await rm(configDir, { recursive: true, force: true })
|
|
270
|
+
}
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test('config import fails when input file does not exist', async () => {
|
|
274
|
+
const configDir = await mkdtemp(join(tmpdir(), 'optimal-cli-smoke-'))
|
|
275
|
+
try {
|
|
276
|
+
const result = await runOptimalCli(
|
|
277
|
+
['config', 'import', '--in', '/nonexistent/path/config.json'],
|
|
278
|
+
{ OPTIMAL_CONFIG_DIR: configDir }
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
assert.equal(result.code, 1, `Expected failure code, got: ${result.code}`)
|
|
282
|
+
assert.match(result.stderr, /Input file not found/)
|
|
283
|
+
} finally {
|
|
284
|
+
await rm(configDir, { recursive: true, force: true })
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test('config sync pull fails gracefully without supabase config', async () => {
|
|
289
|
+
const configDir = await mkdtemp(join(tmpdir(), 'optimal-cli-smoke-'))
|
|
290
|
+
try {
|
|
291
|
+
// Create a config first
|
|
292
|
+
await runOptimalCli(
|
|
293
|
+
['config', 'init', '--owner', 'test-nosupabase', '--profile', 'default'],
|
|
294
|
+
{ OPTIMAL_CONFIG_DIR: configDir }
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
// Try sync pull without supabase env vars - should fail gracefully
|
|
298
|
+
const result = await runOptimalCli(
|
|
299
|
+
['config', 'sync', 'pull', '--profile', 'default'],
|
|
300
|
+
{
|
|
301
|
+
OPTIMAL_CONFIG_DIR: configDir,
|
|
302
|
+
OPTIMAL_SUPABASE_URL: '', // empty = not configured
|
|
303
|
+
OPTIMAL_SUPABASE_ANON_KEY: ''
|
|
304
|
+
}
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
assert.equal(result.code, 1, `Expected failure code, got: ${result.code}`)
|
|
308
|
+
// Should mention missing env vars
|
|
309
|
+
assert.ok(result.stdout.includes('Missing env vars') || result.stderr.includes('Missing env vars'),
|
|
310
|
+
`Expected missing env vars error, got: ${result.stdout} ${result.stderr}`)
|
|
311
|
+
} finally {
|
|
312
|
+
await rm(configDir, { recursive: true, force: true })
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
test('config init --force overwrites existing config', async () => {
|
|
317
|
+
const configDir = await mkdtemp(join(tmpdir(), 'optimal-cli-smoke-'))
|
|
318
|
+
try {
|
|
319
|
+
// Create initial config
|
|
320
|
+
await runOptimalCli(
|
|
321
|
+
['config', 'init', '--owner', 'initial-owner', '--profile', 'default', '--brand', 'CRE-11TRUST'],
|
|
322
|
+
{ OPTIMAL_CONFIG_DIR: configDir }
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
// Verify initial config
|
|
326
|
+
let configPath = join(configDir, 'optimal.config.json')
|
|
327
|
+
let content = await readFile(configPath, 'utf-8')
|
|
328
|
+
let config = JSON.parse(content)
|
|
329
|
+
assert.equal(config.profile.owner, 'initial-owner')
|
|
330
|
+
|
|
331
|
+
// Now init again with --force
|
|
332
|
+
const result = await runOptimalCli(
|
|
333
|
+
['config', 'init', '--owner', 'new-owner', '--profile', 'default', '--brand', 'LIFEINSUR', '--force'],
|
|
334
|
+
{ OPTIMAL_CONFIG_DIR: configDir }
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
assert.equal(result.code, 0, `config init --force failed: ${result.stderr}`)
|
|
338
|
+
assert.match(result.stdout, /Initialized config/)
|
|
339
|
+
|
|
340
|
+
// Verify it was overwritten
|
|
341
|
+
content = await readFile(configPath, 'utf-8')
|
|
342
|
+
config = JSON.parse(content)
|
|
343
|
+
assert.equal(config.profile.owner, 'new-owner')
|
|
344
|
+
assert.equal(config.defaults.brand, 'LIFEINSUR')
|
|
345
|
+
} finally {
|
|
346
|
+
await rm(configDir, { recursive: true, force: true })
|
|
347
|
+
}
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
test('config init without --force fails when config exists', async () => {
|
|
351
|
+
const configDir = await mkdtemp(join(tmpdir(), 'optimal-cli-smoke-'))
|
|
352
|
+
try {
|
|
353
|
+
// Create initial config
|
|
354
|
+
await runOptimalCli(
|
|
355
|
+
['config', 'init', '--owner', 'first-owner', '--profile', 'default'],
|
|
356
|
+
{ OPTIMAL_CONFIG_DIR: configDir }
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
// Try to init again without --force - should fail
|
|
360
|
+
const result = await runOptimalCli(
|
|
361
|
+
['config', 'init', '--owner', 'second-owner', '--profile', 'default'],
|
|
362
|
+
{ OPTIMAL_CONFIG_DIR: configDir }
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
assert.equal(result.code, 1, `Expected failure code, got: ${result.code}`)
|
|
366
|
+
assert.match(result.stderr, /Config already exists/)
|
|
367
|
+
} finally {
|
|
368
|
+
await rm(configDir, { recursive: true, force: true })
|
|
369
|
+
}
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
// Basic CLI smoke tests
|
|
373
|
+
|
|
374
|
+
test('--version shows version number', async () => {
|
|
375
|
+
const result = await runOptimalCli(['--version'], {})
|
|
376
|
+
assert.equal(result.code, 0, `--version should succeed`)
|
|
377
|
+
assert.match(result.stdout, /0\./) // version like 0.1.0
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
test('--help shows help', async () => {
|
|
381
|
+
const result = await runOptimalCli(['--help'], {})
|
|
382
|
+
assert.equal(result.code, 0, `--help should succeed`)
|
|
383
|
+
assert.match(result.stdout, /Usage:/)
|
|
384
|
+
assert.match(result.stdout, /Commands:/)
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
test('config --help shows config subcommands', async () => {
|
|
388
|
+
const result = await runOptimalCli(['config', '--help'], {})
|
|
389
|
+
assert.equal(result.code, 0, `config --help should succeed`)
|
|
390
|
+
assert.match(result.stdout, /init/)
|
|
391
|
+
assert.match(result.stdout, /doctor/)
|
|
392
|
+
assert.match(result.stdout, /export/)
|
|
393
|
+
assert.match(result.stdout, /import/)
|
|
394
|
+
assert.match(result.stdout, /sync/)
|
|
395
|
+
})
|