optimal-cli 1.0.1 → 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 +1418 -0
- 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/budget/projections.ts +561 -0
- package/lib/budget/scenarios.ts +312 -0
- package/lib/cms/publish-blog.ts +129 -0
- package/lib/cms/strapi-client.ts +302 -0
- package/lib/config/registry.ts +229 -0
- package/lib/config/schema.ts +58 -0
- package/lib/config.ts +247 -0
- package/lib/infra/.gitkeep +0 -0
- package/lib/infra/deploy.ts +70 -0
- package/lib/infra/migrate.ts +141 -0
- package/lib/kanban-obsidian.ts +232 -0
- package/lib/kanban-sync.ts +258 -0
- package/lib/kanban.ts +239 -0
- package/lib/newsletter/.gitkeep +0 -0
- package/lib/newsletter/distribute.ts +256 -0
- package/{dist/lib/newsletter/generate-insurance.d.ts → lib/newsletter/generate-insurance.ts} +24 -7
- package/lib/newsletter/generate.ts +735 -0
- package/lib/obsidian-tasks.ts +231 -0
- package/lib/returnpro/.gitkeep +0 -0
- package/lib/returnpro/anomalies.ts +258 -0
- package/lib/returnpro/audit.ts +194 -0
- package/lib/returnpro/diagnose.ts +400 -0
- package/lib/returnpro/kpis.ts +255 -0
- package/lib/returnpro/templates.ts +323 -0
- package/lib/returnpro/upload-income.ts +311 -0
- package/lib/returnpro/upload-netsuite.ts +696 -0
- package/lib/returnpro/upload-r1.ts +563 -0
- package/lib/social/post-generator.ts +468 -0
- package/lib/social/publish.ts +301 -0
- package/lib/social/scraper.ts +503 -0
- package/lib/supabase.ts +25 -0
- package/lib/transactions/delete-batch.ts +258 -0
- package/lib/transactions/ingest.ts +659 -0
- package/lib/transactions/stamp.ts +654 -0
- package/package.json +5 -18
- 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/dist/bin/optimal.d.ts +0 -2
- package/dist/bin/optimal.js +0 -1590
- package/dist/lib/assets/index.d.ts +0 -79
- package/dist/lib/assets/index.js +0 -153
- package/dist/lib/assets.d.ts +0 -20
- package/dist/lib/assets.js +0 -112
- package/dist/lib/auth/index.d.ts +0 -83
- package/dist/lib/auth/index.js +0 -146
- package/dist/lib/board/index.d.ts +0 -39
- package/dist/lib/board/index.js +0 -285
- package/dist/lib/board/types.d.ts +0 -111
- package/dist/lib/board/types.js +0 -1
- package/dist/lib/bot/claim.d.ts +0 -3
- package/dist/lib/bot/claim.js +0 -20
- package/dist/lib/bot/coordinator.d.ts +0 -27
- package/dist/lib/bot/coordinator.js +0 -178
- package/dist/lib/bot/heartbeat.d.ts +0 -6
- package/dist/lib/bot/heartbeat.js +0 -30
- package/dist/lib/bot/index.d.ts +0 -9
- package/dist/lib/bot/index.js +0 -6
- package/dist/lib/bot/protocol.d.ts +0 -12
- package/dist/lib/bot/protocol.js +0 -74
- package/dist/lib/bot/reporter.d.ts +0 -3
- package/dist/lib/bot/reporter.js +0 -27
- package/dist/lib/bot/skills.d.ts +0 -26
- package/dist/lib/bot/skills.js +0 -69
- package/dist/lib/budget/projections.d.ts +0 -115
- package/dist/lib/budget/projections.js +0 -384
- package/dist/lib/budget/scenarios.d.ts +0 -93
- package/dist/lib/budget/scenarios.js +0 -214
- package/dist/lib/cms/publish-blog.d.ts +0 -62
- package/dist/lib/cms/publish-blog.js +0 -74
- package/dist/lib/cms/strapi-client.d.ts +0 -123
- package/dist/lib/cms/strapi-client.js +0 -213
- package/dist/lib/config/registry.d.ts +0 -17
- package/dist/lib/config/registry.js +0 -182
- package/dist/lib/config/schema.d.ts +0 -31
- package/dist/lib/config/schema.js +0 -25
- package/dist/lib/config.d.ts +0 -55
- package/dist/lib/config.js +0 -206
- package/dist/lib/errors.d.ts +0 -25
- package/dist/lib/errors.js +0 -91
- package/dist/lib/format.d.ts +0 -28
- package/dist/lib/format.js +0 -98
- package/dist/lib/infra/deploy.d.ts +0 -29
- package/dist/lib/infra/deploy.js +0 -58
- package/dist/lib/infra/migrate.d.ts +0 -34
- package/dist/lib/infra/migrate.js +0 -103
- package/dist/lib/newsletter/distribute.d.ts +0 -52
- package/dist/lib/newsletter/distribute.js +0 -193
- package/dist/lib/newsletter/generate-insurance.js +0 -36
- package/dist/lib/newsletter/generate.d.ts +0 -104
- package/dist/lib/newsletter/generate.js +0 -571
- package/dist/lib/returnpro/anomalies.d.ts +0 -64
- package/dist/lib/returnpro/anomalies.js +0 -166
- package/dist/lib/returnpro/audit.d.ts +0 -32
- package/dist/lib/returnpro/audit.js +0 -147
- package/dist/lib/returnpro/diagnose.d.ts +0 -52
- package/dist/lib/returnpro/diagnose.js +0 -281
- package/dist/lib/returnpro/kpis.d.ts +0 -32
- package/dist/lib/returnpro/kpis.js +0 -192
- package/dist/lib/returnpro/templates.d.ts +0 -48
- package/dist/lib/returnpro/templates.js +0 -229
- package/dist/lib/returnpro/upload-income.d.ts +0 -25
- package/dist/lib/returnpro/upload-income.js +0 -235
- package/dist/lib/returnpro/upload-netsuite.d.ts +0 -37
- package/dist/lib/returnpro/upload-netsuite.js +0 -566
- package/dist/lib/returnpro/upload-r1.d.ts +0 -48
- package/dist/lib/returnpro/upload-r1.js +0 -398
- package/dist/lib/returnpro/validate.d.ts +0 -37
- package/dist/lib/returnpro/validate.js +0 -124
- package/dist/lib/social/meta.d.ts +0 -90
- package/dist/lib/social/meta.js +0 -160
- package/dist/lib/social/post-generator.d.ts +0 -83
- package/dist/lib/social/post-generator.js +0 -333
- package/dist/lib/social/publish.d.ts +0 -66
- package/dist/lib/social/publish.js +0 -226
- package/dist/lib/social/scraper.d.ts +0 -67
- package/dist/lib/social/scraper.js +0 -361
- package/dist/lib/supabase.d.ts +0 -4
- package/dist/lib/supabase.js +0 -20
- package/dist/lib/transactions/delete-batch.d.ts +0 -60
- package/dist/lib/transactions/delete-batch.js +0 -203
- package/dist/lib/transactions/ingest.d.ts +0 -43
- package/dist/lib/transactions/ingest.js +0 -555
- package/dist/lib/transactions/stamp.d.ts +0 -51
- package/dist/lib/transactions/stamp.js +0 -524
- package/docs/CLI-REFERENCE.md +0 -361
package/lib/config.ts
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { createClient, SupabaseClient } from '@supabase/supabase-js'
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
const CONFIG_DIR = join(homedir(), '.optimal')
|
|
7
|
+
const LOCAL_CONFIG_PATH = join(CONFIG_DIR, 'config.json')
|
|
8
|
+
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json')
|
|
9
|
+
|
|
10
|
+
// Get Supabase client for OptimalOS instance (stores CLI configs)
|
|
11
|
+
function getOptimalSupabase(): SupabaseClient {
|
|
12
|
+
const url = process.env.OPTIMAL_SUPABASE_URL
|
|
13
|
+
const key = process.env.OPTIMAL_SUPABASE_SERVICE_KEY
|
|
14
|
+
if (!url || !key) {
|
|
15
|
+
throw new Error('OPTIMAL_SUPABASE_URL and OPTIMAL_SUPABASE_SERVICE_KEY must be set')
|
|
16
|
+
}
|
|
17
|
+
return createClient(url, key)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ConfigRecord {
|
|
21
|
+
id: string
|
|
22
|
+
agent_name: string
|
|
23
|
+
config_json: Record<string, unknown>
|
|
24
|
+
version: string
|
|
25
|
+
created_at: string
|
|
26
|
+
updated_at: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Initialize local config directory
|
|
31
|
+
*/
|
|
32
|
+
export function initConfigDir(): void {
|
|
33
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
34
|
+
import('node:fs').then(fs => fs.mkdirSync(CONFIG_DIR, { recursive: true }))
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Load local openclaw.json
|
|
40
|
+
*/
|
|
41
|
+
export function loadLocalConfig(): Record<string, unknown> | null {
|
|
42
|
+
if (!existsSync(OPENCLAW_CONFIG_PATH)) {
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const raw = readFileSync(OPENCLAW_CONFIG_PATH, 'utf-8')
|
|
47
|
+
return JSON.parse(raw)
|
|
48
|
+
} catch {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Save config to local openclaw.json
|
|
55
|
+
*/
|
|
56
|
+
export function saveLocalConfig(config: Record<string, unknown>): void {
|
|
57
|
+
writeFileSync(OPENCLAW_CONFIG_PATH, JSON.stringify(config, null, 2))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Push current openclaw.json to Supabase
|
|
62
|
+
*/
|
|
63
|
+
export async function pushConfig(agentName: string): Promise<{ id: string; version: string }> {
|
|
64
|
+
const supabase = getOptimalSupabase()
|
|
65
|
+
const config = loadLocalConfig()
|
|
66
|
+
|
|
67
|
+
if (!config) {
|
|
68
|
+
throw new Error(`No config found at ${OPENCLAW_CONFIG_PATH}`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Generate version timestamp
|
|
72
|
+
const version = new Date().toISOString()
|
|
73
|
+
|
|
74
|
+
// Check if config exists for this agent
|
|
75
|
+
const { data: existing } = await supabase
|
|
76
|
+
.from('agent_configs')
|
|
77
|
+
.select('id')
|
|
78
|
+
.eq('agent_name', agentName)
|
|
79
|
+
.single()
|
|
80
|
+
|
|
81
|
+
let result
|
|
82
|
+
if (existing) {
|
|
83
|
+
// Update existing
|
|
84
|
+
const { data, error } = await supabase
|
|
85
|
+
.from('agent_configs')
|
|
86
|
+
.update({
|
|
87
|
+
config_json: config,
|
|
88
|
+
version,
|
|
89
|
+
updated_at: version,
|
|
90
|
+
})
|
|
91
|
+
.eq('id', existing.id)
|
|
92
|
+
.select()
|
|
93
|
+
.single()
|
|
94
|
+
|
|
95
|
+
if (error) throw error
|
|
96
|
+
result = data
|
|
97
|
+
} else {
|
|
98
|
+
// Insert new
|
|
99
|
+
const { data, error } = await supabase
|
|
100
|
+
.from('agent_configs')
|
|
101
|
+
.insert({
|
|
102
|
+
agent_name: agentName,
|
|
103
|
+
config_json: config,
|
|
104
|
+
version,
|
|
105
|
+
})
|
|
106
|
+
.select()
|
|
107
|
+
.single()
|
|
108
|
+
|
|
109
|
+
if (error) throw error
|
|
110
|
+
result = data
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { id: result.id, version }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Pull config from Supabase and save to local openclaw.json
|
|
118
|
+
*/
|
|
119
|
+
export async function pullConfig(agentName: string): Promise<ConfigRecord> {
|
|
120
|
+
const supabase = getOptimalSupabase()
|
|
121
|
+
|
|
122
|
+
const { data, error } = await supabase
|
|
123
|
+
.from('agent_configs')
|
|
124
|
+
.select('*')
|
|
125
|
+
.eq('agent_name', agentName)
|
|
126
|
+
.order('updated_at', { ascending: false })
|
|
127
|
+
.limit(1)
|
|
128
|
+
.single()
|
|
129
|
+
|
|
130
|
+
if (error) {
|
|
131
|
+
throw new Error(`No config found for agent: ${agentName}`)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Save to local
|
|
135
|
+
saveLocalConfig(data.config_json)
|
|
136
|
+
|
|
137
|
+
return data as ConfigRecord
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* List all saved agent configs
|
|
142
|
+
*/
|
|
143
|
+
export async function listConfigs(): Promise<Array<{ agent_name: string; version: string; updated_at: string }>> {
|
|
144
|
+
const supabase = getOptimalSupabase()
|
|
145
|
+
|
|
146
|
+
const { data, error } = await supabase
|
|
147
|
+
.from('agent_configs')
|
|
148
|
+
.select('agent_name, version, updated_at')
|
|
149
|
+
.order('updated_at', { ascending: false })
|
|
150
|
+
|
|
151
|
+
if (error) {
|
|
152
|
+
throw new Error(`Failed to list configs: ${error.message}`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return data || []
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Compare local config with cloud version
|
|
160
|
+
*/
|
|
161
|
+
export async function diffConfig(agentName: string): Promise<{
|
|
162
|
+
local: Record<string, unknown> | null
|
|
163
|
+
cloud: ConfigRecord | null
|
|
164
|
+
differences: string[]
|
|
165
|
+
}> {
|
|
166
|
+
const local = loadLocalConfig()
|
|
167
|
+
let cloud: ConfigRecord | null = null
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const supabase = getOptimalSupabase()
|
|
171
|
+
const { data } = await supabase
|
|
172
|
+
.from('agent_configs')
|
|
173
|
+
.select('*')
|
|
174
|
+
.eq('agent_name', agentName)
|
|
175
|
+
.single()
|
|
176
|
+
cloud = data as ConfigRecord
|
|
177
|
+
} catch {
|
|
178
|
+
// Cloud config doesn't exist
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const differences: string[] = []
|
|
182
|
+
|
|
183
|
+
if (!local && !cloud) {
|
|
184
|
+
differences.push('No local or cloud config found')
|
|
185
|
+
} else if (!local) {
|
|
186
|
+
differences.push('No local config (cloud exists)')
|
|
187
|
+
} else if (!cloud) {
|
|
188
|
+
differences.push('No cloud config (local exists)')
|
|
189
|
+
} else {
|
|
190
|
+
// Simple diff on top-level keys
|
|
191
|
+
const localKeys = Object.keys(local).sort()
|
|
192
|
+
const cloudKeys = Object.keys(cloud.config_json).sort()
|
|
193
|
+
|
|
194
|
+
if (JSON.stringify(localKeys) !== JSON.stringify(cloudKeys)) {
|
|
195
|
+
differences.push('Top-level keys differ')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Check version
|
|
199
|
+
const localMeta = (local as any).meta
|
|
200
|
+
if (localMeta?.lastTouchedVersion !== cloud.version) {
|
|
201
|
+
differences.push(`Version mismatch: local=${localMeta?.lastTouchedVersion}, cloud=${cloud.version}`)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { local, cloud, differences }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Sync config (two-way merge)
|
|
210
|
+
*/
|
|
211
|
+
export async function syncConfig(agentName: string): Promise<{
|
|
212
|
+
action: 'pushed' | 'pulled' | 'merged' | 'none'
|
|
213
|
+
message: string
|
|
214
|
+
}> {
|
|
215
|
+
const { local, cloud, differences } = await diffConfig(agentName)
|
|
216
|
+
|
|
217
|
+
if (!local && !cloud) {
|
|
218
|
+
return { action: 'none', message: 'No configs to sync' }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!cloud) {
|
|
222
|
+
// Only local exists - push
|
|
223
|
+
const result = await pushConfig(agentName)
|
|
224
|
+
return { action: 'pushed', message: `Pushed to cloud (version ${result.version})` }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!local) {
|
|
228
|
+
// Only cloud exists - pull
|
|
229
|
+
await pullConfig(agentName)
|
|
230
|
+
return { action: 'pulled', message: `Pulled from cloud (version ${cloud.version})` }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Both exist - compare timestamps
|
|
234
|
+
const localTime = (local as any).meta?.lastTouchedAt || '1970-01-01'
|
|
235
|
+
const localVersion = (local as any).meta?.lastTouchedVersion || 'unknown'
|
|
236
|
+
const cloudTime = cloud.updated_at
|
|
237
|
+
|
|
238
|
+
if (localTime > cloudTime) {
|
|
239
|
+
const result = await pushConfig(agentName)
|
|
240
|
+
return { action: 'pushed', message: `Local is newer - pushed to cloud (version ${result.version})` }
|
|
241
|
+
} else if (cloudTime > localTime) {
|
|
242
|
+
await pullConfig(agentName)
|
|
243
|
+
return { action: 'pulled', message: `Cloud is newer - pulled from cloud (version ${cloud.version})` }
|
|
244
|
+
} else {
|
|
245
|
+
return { action: 'none', message: 'Configs are in sync' }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
|
|
4
|
+
const run = promisify(execFile)
|
|
5
|
+
|
|
6
|
+
/** Map of short app names to absolute filesystem paths. */
|
|
7
|
+
const APP_PATHS: Record<string, string> = {
|
|
8
|
+
'dashboard-returnpro': '/home/optimal/dashboard-returnpro',
|
|
9
|
+
'optimalos': '/home/optimal/optimalos',
|
|
10
|
+
'portfolio': '/home/optimal/portfolio-2026',
|
|
11
|
+
'newsletter-preview': '/home/optimal/projects/newsletter-preview',
|
|
12
|
+
'wes': '/home/optimal/wes-dashboard',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* List all available app names that can be deployed.
|
|
17
|
+
*/
|
|
18
|
+
export function listApps(): string[] {
|
|
19
|
+
return Object.keys(APP_PATHS)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve an app name to its absolute filesystem path.
|
|
24
|
+
* Throws if the app name is unknown.
|
|
25
|
+
*/
|
|
26
|
+
export function getAppPath(appName: string): string {
|
|
27
|
+
const appPath = APP_PATHS[appName]
|
|
28
|
+
if (!appPath) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Unknown app: ${appName}. Available: ${Object.keys(APP_PATHS).join(', ')}`
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
return appPath
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Deploy an app to Vercel using the `vercel` CLI.
|
|
38
|
+
*
|
|
39
|
+
* Uses `execFile` (not `exec`) to avoid shell injection.
|
|
40
|
+
* The `--cwd` flag tells Vercel which project directory to deploy.
|
|
41
|
+
*
|
|
42
|
+
* @param appName - Short name from APP_PATHS (e.g. 'portfolio', 'dashboard-returnpro')
|
|
43
|
+
* @param prod - If true, deploys to production (--prod flag). Otherwise preview.
|
|
44
|
+
* @returns The deployment URL printed by Vercel CLI.
|
|
45
|
+
*/
|
|
46
|
+
export async function deploy(appName: string, prod = false): Promise<string> {
|
|
47
|
+
const appPath = getAppPath(appName)
|
|
48
|
+
const args = prod
|
|
49
|
+
? ['--prod', '--cwd', appPath]
|
|
50
|
+
: ['--cwd', appPath]
|
|
51
|
+
const { stdout } = await run('vercel', args, { timeout: 120_000 })
|
|
52
|
+
return stdout.trim()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Run the Optimal workstation health check script.
|
|
57
|
+
*
|
|
58
|
+
* Checks: n8n, Affine (Docker + HTTP), Strapi CMS (systemd + HTTP),
|
|
59
|
+
* Git repo sync status, Docker containers, and OptimalOS dev server.
|
|
60
|
+
*
|
|
61
|
+
* @returns The full text output of the health check script.
|
|
62
|
+
*/
|
|
63
|
+
export async function healthCheck(): Promise<string> {
|
|
64
|
+
const { stdout } = await run(
|
|
65
|
+
'bash',
|
|
66
|
+
['/home/optimal/scripts/health-check.sh'],
|
|
67
|
+
{ timeout: 30_000 }
|
|
68
|
+
)
|
|
69
|
+
return stdout.trim()
|
|
70
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import 'dotenv/config'
|
|
2
|
+
import { execFile } from 'node:child_process'
|
|
3
|
+
import { promisify } from 'node:util'
|
|
4
|
+
import { readdir, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
const run = promisify(execFile)
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Types
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
export interface MigrateOptions {
|
|
14
|
+
target: 'returnpro' | 'optimalos'
|
|
15
|
+
dryRun?: boolean // if true, just show what would be pushed
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MigrateResult {
|
|
19
|
+
success: boolean
|
|
20
|
+
target: string
|
|
21
|
+
output: string
|
|
22
|
+
errors: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Constants
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/** Hardcoded project directories — these live on Carlos's machine. */
|
|
30
|
+
const PROJECT_DIRS: Record<'returnpro' | 'optimalos', string> = {
|
|
31
|
+
returnpro: '/home/optimal/dashboard-returnpro',
|
|
32
|
+
optimalos: '/home/optimal/optimalos',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Helpers
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
function getProjectDir(target: 'returnpro' | 'optimalos'): string {
|
|
40
|
+
return PROJECT_DIRS[target]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function migrationsDir(target: 'returnpro' | 'optimalos'): string {
|
|
44
|
+
return join(getProjectDir(target), 'supabase', 'migrations')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Generate a timestamp string in YYYYMMDDHHMMSS format (UTC).
|
|
49
|
+
*/
|
|
50
|
+
function timestamp(): string {
|
|
51
|
+
const now = new Date()
|
|
52
|
+
const pad = (n: number, len = 2) => String(n).padStart(len, '0')
|
|
53
|
+
return [
|
|
54
|
+
now.getUTCFullYear(),
|
|
55
|
+
pad(now.getUTCMonth() + 1),
|
|
56
|
+
pad(now.getUTCDate()),
|
|
57
|
+
pad(now.getUTCHours()),
|
|
58
|
+
pad(now.getUTCMinutes()),
|
|
59
|
+
pad(now.getUTCSeconds()),
|
|
60
|
+
].join('')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Public API
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Run `supabase db push --linked` (or `--dry-run` if requested) against the
|
|
69
|
+
* given target project directory.
|
|
70
|
+
*
|
|
71
|
+
* Uses `execFile` (not `exec`) to avoid shell injection.
|
|
72
|
+
* The `cwd` option switches the Supabase CLI into the correct project.
|
|
73
|
+
*/
|
|
74
|
+
export async function migrateDb(opts: MigrateOptions): Promise<MigrateResult> {
|
|
75
|
+
const { target, dryRun = false } = opts
|
|
76
|
+
const projectDir = getProjectDir(target)
|
|
77
|
+
|
|
78
|
+
const args: string[] = ['db', 'push', '--linked']
|
|
79
|
+
if (dryRun) args.push('--dry-run')
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const { stdout, stderr } = await run('supabase', args, {
|
|
83
|
+
cwd: projectDir,
|
|
84
|
+
timeout: 120_000,
|
|
85
|
+
})
|
|
86
|
+
return {
|
|
87
|
+
success: true,
|
|
88
|
+
target,
|
|
89
|
+
output: stdout.trim(),
|
|
90
|
+
errors: stderr.trim(),
|
|
91
|
+
}
|
|
92
|
+
} catch (err: unknown) {
|
|
93
|
+
const e = err as { stdout?: string; stderr?: string; message?: string }
|
|
94
|
+
return {
|
|
95
|
+
success: false,
|
|
96
|
+
target,
|
|
97
|
+
output: (e.stdout ?? '').trim(),
|
|
98
|
+
errors: (e.stderr ?? e.message ?? String(err)).trim(),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* List migration `.sql` files in the target's `supabase/migrations/` directory,
|
|
105
|
+
* sorted chronologically (by filename, which starts with a YYYYMMDDHHMMSS prefix).
|
|
106
|
+
*
|
|
107
|
+
* Returns only filenames, not full paths.
|
|
108
|
+
*/
|
|
109
|
+
export async function listPendingMigrations(
|
|
110
|
+
target: 'returnpro' | 'optimalos'
|
|
111
|
+
): Promise<string[]> {
|
|
112
|
+
const dir = migrationsDir(target)
|
|
113
|
+
const entries = await readdir(dir)
|
|
114
|
+
return entries
|
|
115
|
+
.filter((f) => f.endsWith('.sql'))
|
|
116
|
+
.sort() // lexicographic == chronological given the YYYYMMDDHHMMSS prefix
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Create a new empty migration file in the target's `supabase/migrations/`
|
|
121
|
+
* directory.
|
|
122
|
+
*
|
|
123
|
+
* The filename format is `{YYYYMMDDHHMMSS}_{name}.sql` (UTC timestamp).
|
|
124
|
+
* Returns the full absolute path of the created file.
|
|
125
|
+
*/
|
|
126
|
+
export async function createMigration(
|
|
127
|
+
target: 'returnpro' | 'optimalos',
|
|
128
|
+
name: string
|
|
129
|
+
): Promise<string> {
|
|
130
|
+
const sanitized = name.replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_]/g, '')
|
|
131
|
+
const filename = `${timestamp()}_${sanitized}.sql`
|
|
132
|
+
const fullPath = join(migrationsDir(target), filename)
|
|
133
|
+
|
|
134
|
+
await writeFile(
|
|
135
|
+
fullPath,
|
|
136
|
+
`-- Migration: ${sanitized}\n-- Target: ${target}\n-- Created: ${new Date().toISOString()}\n\n`,
|
|
137
|
+
{ encoding: 'utf8' }
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return fullPath
|
|
141
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Obsidian-backed Kanban board operations
|
|
3
|
+
* Works directly with markdown task files in the tasks vault
|
|
4
|
+
*/
|
|
5
|
+
import { readFile, writeFile, readdir } from 'node:fs/promises'
|
|
6
|
+
import { existsSync } from 'node:fs'
|
|
7
|
+
import { join, basename } from 'node:path'
|
|
8
|
+
import { glob } from 'glob'
|
|
9
|
+
|
|
10
|
+
const TASKS_DIR = process.env.OPTIMAL_TASKS_DIR || '/home/oracle/Documents/optimal/tasks'
|
|
11
|
+
|
|
12
|
+
export interface ObsidianTask {
|
|
13
|
+
id: string
|
|
14
|
+
title: string
|
|
15
|
+
description: string
|
|
16
|
+
status: 'pending' | 'in-progress' | 'done'
|
|
17
|
+
priority: number
|
|
18
|
+
owner: string | null
|
|
19
|
+
assignee: string | null
|
|
20
|
+
project: string | null
|
|
21
|
+
tags: string[]
|
|
22
|
+
createdAt: string | null
|
|
23
|
+
updatedAt: string | null
|
|
24
|
+
source: string | null
|
|
25
|
+
filePath: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface BoardColumn {
|
|
29
|
+
name: string
|
|
30
|
+
status: string
|
|
31
|
+
tasks: ObsidianTask[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseFrontmatter(content: string): Record<string, string> {
|
|
35
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
|
|
36
|
+
if (!fmMatch) return {}
|
|
37
|
+
|
|
38
|
+
const fm: Record<string, string> = {}
|
|
39
|
+
const lines = fmMatch[1].split('\n')
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
const colonIdx = line.indexOf(':')
|
|
42
|
+
if (colonIdx === -1) continue
|
|
43
|
+
const key = line.slice(0, colonIdx).trim()
|
|
44
|
+
const value = line.slice(colonIdx + 1).trim()
|
|
45
|
+
fm[key] = value
|
|
46
|
+
}
|
|
47
|
+
return fm
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getTaskIdFromFilename(filename: string): string {
|
|
51
|
+
// filename format: task__description__hash.md
|
|
52
|
+
return filename.replace(/^task__/, '').replace(/\.md$/, '')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function listTaskFiles(): Promise<string[]> {
|
|
56
|
+
const files = await readdir(TASKS_DIR)
|
|
57
|
+
return files
|
|
58
|
+
.filter(f => f.startsWith('task__') && f.endsWith('.md'))
|
|
59
|
+
.map(f => join(TASKS_DIR, f))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function loadTaskFromFile(filePath: string): Promise<ObsidianTask | null> {
|
|
63
|
+
try {
|
|
64
|
+
const content = await readFile(filePath, 'utf-8')
|
|
65
|
+
const fm = parseFrontmatter(content)
|
|
66
|
+
|
|
67
|
+
// Extract body as description (first paragraph after frontmatter)
|
|
68
|
+
const bodyMatch = content.match(/^---\n[\s\S]*?\n---\n\n?(.*)$/)
|
|
69
|
+
const description = bodyMatch ? bodyMatch[1].trim() : ''
|
|
70
|
+
|
|
71
|
+
// Parse status from frontmatter
|
|
72
|
+
let status: 'pending' | 'in-progress' | 'done' = 'pending'
|
|
73
|
+
const statusStr = fm.status?.toLowerCase()
|
|
74
|
+
if (statusStr === 'in-progress' || statusStr === 'inprogress') status = 'in-progress'
|
|
75
|
+
else if (statusStr === 'done' || statusStr === 'completed') status = 'done'
|
|
76
|
+
|
|
77
|
+
// Parse tags from frontmatter
|
|
78
|
+
const tagsStr = fm.tags || '[]'
|
|
79
|
+
let tags: string[] = []
|
|
80
|
+
try {
|
|
81
|
+
tags = JSON.parse(tagsStr)
|
|
82
|
+
} catch {
|
|
83
|
+
if (fm.tags) tags = fm.tags.split(',').map(t => t.trim())
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
id: fm.id || basename(filePath).replace(/\.md$/, ''),
|
|
88
|
+
title: fm.title || basename(filePath, '.md'),
|
|
89
|
+
description: description.slice(0, 200),
|
|
90
|
+
status,
|
|
91
|
+
priority: parseInt(fm.priority) || 3,
|
|
92
|
+
owner: fm.owner || null,
|
|
93
|
+
assignee: fm.assignee || null,
|
|
94
|
+
project: fm.project || null,
|
|
95
|
+
tags,
|
|
96
|
+
createdAt: fm.created_at || null,
|
|
97
|
+
updatedAt: fm.updated_at || null,
|
|
98
|
+
source: fm.source || null,
|
|
99
|
+
filePath,
|
|
100
|
+
}
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error(`Failed to load task from ${filePath}:`, err)
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function loadAllTasks(): Promise<ObsidianTask[]> {
|
|
108
|
+
const files = await listTaskFiles()
|
|
109
|
+
const tasks: ObsidianTask[] = []
|
|
110
|
+
|
|
111
|
+
for (const file of files) {
|
|
112
|
+
const task = await loadTaskFromFile(file)
|
|
113
|
+
if (task) tasks.push(task)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return tasks
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function getBoardByProject(projectSlug?: string): Promise<BoardColumn[]> {
|
|
120
|
+
const allTasks = await loadAllTasks()
|
|
121
|
+
|
|
122
|
+
// Filter by project if specified
|
|
123
|
+
const filtered = projectSlug
|
|
124
|
+
? allTasks.filter(t => t.project === projectSlug)
|
|
125
|
+
: allTasks
|
|
126
|
+
|
|
127
|
+
return [
|
|
128
|
+
{ name: 'To Do', status: 'pending', tasks: filtered.filter(t => t.status === 'pending') },
|
|
129
|
+
{ name: 'In Progress', status: 'in-progress', tasks: filtered.filter(t => t.status === 'in-progress') },
|
|
130
|
+
{ name: 'Done', status: 'done', tasks: filtered.filter(t => t.status === 'done') },
|
|
131
|
+
]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function findTaskById(taskId: string): Promise<ObsidianTask | null> {
|
|
135
|
+
const files = await listTaskFiles()
|
|
136
|
+
const targetFile = files.find(f => f.includes(taskId) || f.includes(taskId.replace(/-/g, '')))
|
|
137
|
+
|
|
138
|
+
if (!targetFile) return null
|
|
139
|
+
return loadTaskFromFile(targetFile)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface UpdateTaskResult {
|
|
143
|
+
ok: boolean
|
|
144
|
+
message: string
|
|
145
|
+
task?: ObsidianTask
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function updateTaskStatus(taskId: string, newStatus: string, message?: string): Promise<UpdateTaskResult> {
|
|
149
|
+
const task = await findTaskById(taskId)
|
|
150
|
+
if (!task) {
|
|
151
|
+
return { ok: false, message: `Task not found: ${taskId}` }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Map status
|
|
155
|
+
let status = 'pending'
|
|
156
|
+
if (newStatus === 'in-progress' || newStatus === 'inprogress') status = 'in-progress'
|
|
157
|
+
else if (newStatus === 'done' || newStatus === 'completed') status = 'done'
|
|
158
|
+
else if (newStatus === 'pending') status = 'pending'
|
|
159
|
+
else return { ok: false, message: `Invalid status: ${newStatus}` }
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
let content = await readFile(task.filePath, 'utf-8')
|
|
163
|
+
const now = new Date().toISOString()
|
|
164
|
+
|
|
165
|
+
// Update frontmatter status
|
|
166
|
+
content = content.replace(/^status:.*$/m, `status: ${status}`)
|
|
167
|
+
content = content.replace(/^updated_at:.*$/m, `updated_at: ${now}`)
|
|
168
|
+
|
|
169
|
+
// Add log entry if message provided
|
|
170
|
+
if (message) {
|
|
171
|
+
content = content.replace(
|
|
172
|
+
/^## progress log$/m,
|
|
173
|
+
`## progress log\n- ${now} ${message}`
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
await writeFile(task.filePath, content, 'utf-8')
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
ok: true,
|
|
181
|
+
message: `Updated task ${taskId} status to ${status}`,
|
|
182
|
+
task: await loadTaskFromFile(task.filePath) || undefined
|
|
183
|
+
}
|
|
184
|
+
} catch (err) {
|
|
185
|
+
return { ok: false, message: `Failed to update: ${err}` }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function createTask(options: {
|
|
190
|
+
title: string
|
|
191
|
+
description?: string
|
|
192
|
+
project?: string
|
|
193
|
+
priority?: number
|
|
194
|
+
owner?: string
|
|
195
|
+
assignee?: string
|
|
196
|
+
tags?: string[]
|
|
197
|
+
}): Promise<UpdateTaskResult> {
|
|
198
|
+
const id = `task__${options.title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')}_${Date.now().toString(36)}`
|
|
199
|
+
const now = new Date().toISOString()
|
|
200
|
+
|
|
201
|
+
const frontmatter = `---
|
|
202
|
+
id: ${id}
|
|
203
|
+
type: task
|
|
204
|
+
status: pending
|
|
205
|
+
owner: ${options.owner || 'oracle'}
|
|
206
|
+
assignee: ${options.assignee || ''}
|
|
207
|
+
project: ${options.project || ''}
|
|
208
|
+
priority: ${options.priority || 3}
|
|
209
|
+
source: cli:board:create
|
|
210
|
+
created_at: ${now}
|
|
211
|
+
updated_at: ${now}
|
|
212
|
+
tags: ${JSON.stringify(options.tags || [])}
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
# ${options.title}
|
|
216
|
+
|
|
217
|
+
${options.description || ''}
|
|
218
|
+
`.trim()
|
|
219
|
+
|
|
220
|
+
const filePath = join(TASKS_DIR, `${id}.md`)
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
await writeFile(filePath, frontmatter, 'utf-8')
|
|
224
|
+
return {
|
|
225
|
+
ok: true,
|
|
226
|
+
message: `Created task: ${options.title}`,
|
|
227
|
+
task: await loadTaskFromFile(filePath) || undefined
|
|
228
|
+
}
|
|
229
|
+
} catch (err) {
|
|
230
|
+
return { ok: false, message: `Failed to create task: ${err}` }
|
|
231
|
+
}
|
|
232
|
+
}
|