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.
Files changed (185) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/.claude-plugin/plugin.json +10 -0
  3. package/.env.example +17 -0
  4. package/CLAUDE.md +67 -0
  5. package/COMMANDS.md +264 -0
  6. package/PUBLISH.md +70 -0
  7. package/agents/content-ops.md +2 -2
  8. package/agents/financial-ops.md +2 -2
  9. package/agents/infra-ops.md +2 -2
  10. package/apps/.gitkeep +0 -0
  11. package/bin/optimal.ts +1418 -0
  12. package/docs/MIGRATION_NEEDED.md +37 -0
  13. package/docs/plans/.gitkeep +0 -0
  14. package/docs/plans/optimal-cli-config-registry-v1.md +71 -0
  15. package/hooks/.gitkeep +0 -0
  16. package/lib/budget/projections.ts +561 -0
  17. package/lib/budget/scenarios.ts +312 -0
  18. package/lib/cms/publish-blog.ts +129 -0
  19. package/lib/cms/strapi-client.ts +302 -0
  20. package/lib/config/registry.ts +229 -0
  21. package/lib/config/schema.ts +58 -0
  22. package/lib/config.ts +247 -0
  23. package/lib/infra/.gitkeep +0 -0
  24. package/lib/infra/deploy.ts +70 -0
  25. package/lib/infra/migrate.ts +141 -0
  26. package/lib/kanban-obsidian.ts +232 -0
  27. package/lib/kanban-sync.ts +258 -0
  28. package/lib/kanban.ts +239 -0
  29. package/lib/newsletter/.gitkeep +0 -0
  30. package/lib/newsletter/distribute.ts +256 -0
  31. package/{dist/lib/newsletter/generate-insurance.d.ts → lib/newsletter/generate-insurance.ts} +24 -7
  32. package/lib/newsletter/generate.ts +735 -0
  33. package/lib/obsidian-tasks.ts +231 -0
  34. package/lib/returnpro/.gitkeep +0 -0
  35. package/lib/returnpro/anomalies.ts +258 -0
  36. package/lib/returnpro/audit.ts +194 -0
  37. package/lib/returnpro/diagnose.ts +400 -0
  38. package/lib/returnpro/kpis.ts +255 -0
  39. package/lib/returnpro/templates.ts +323 -0
  40. package/lib/returnpro/upload-income.ts +311 -0
  41. package/lib/returnpro/upload-netsuite.ts +696 -0
  42. package/lib/returnpro/upload-r1.ts +563 -0
  43. package/lib/social/post-generator.ts +468 -0
  44. package/lib/social/publish.ts +301 -0
  45. package/lib/social/scraper.ts +503 -0
  46. package/lib/supabase.ts +25 -0
  47. package/lib/transactions/delete-batch.ts +258 -0
  48. package/lib/transactions/ingest.ts +659 -0
  49. package/lib/transactions/stamp.ts +654 -0
  50. package/package.json +5 -18
  51. package/pnpm-workspace.yaml +3 -0
  52. package/scripts/check-table.ts +24 -0
  53. package/scripts/create-tables.ts +94 -0
  54. package/scripts/migrate-kanban.sh +28 -0
  55. package/scripts/migrate-v2.ts +78 -0
  56. package/scripts/migrate.ts +79 -0
  57. package/scripts/run-migration.ts +59 -0
  58. package/scripts/seed-board.ts +203 -0
  59. package/scripts/test-kanban.ts +21 -0
  60. package/skills/audit-financials/SKILL.md +33 -0
  61. package/skills/board-create/SKILL.md +28 -0
  62. package/skills/board-update/SKILL.md +27 -0
  63. package/skills/board-view/SKILL.md +27 -0
  64. package/skills/delete-batch/SKILL.md +77 -0
  65. package/skills/deploy/SKILL.md +40 -0
  66. package/skills/diagnose-months/SKILL.md +68 -0
  67. package/skills/distribute-newsletter/SKILL.md +58 -0
  68. package/skills/export-budget/SKILL.md +44 -0
  69. package/skills/export-kpis/SKILL.md +52 -0
  70. package/skills/generate-netsuite-template/SKILL.md +51 -0
  71. package/skills/generate-newsletter/SKILL.md +53 -0
  72. package/skills/generate-newsletter-insurance/SKILL.md +59 -0
  73. package/skills/generate-social-posts/SKILL.md +67 -0
  74. package/skills/health-check/SKILL.md +42 -0
  75. package/skills/ingest-transactions/SKILL.md +51 -0
  76. package/skills/manage-cms/SKILL.md +50 -0
  77. package/skills/manage-scenarios/SKILL.md +83 -0
  78. package/skills/migrate-db/SKILL.md +79 -0
  79. package/skills/preview-newsletter/SKILL.md +50 -0
  80. package/skills/project-budget/SKILL.md +60 -0
  81. package/skills/publish-blog/SKILL.md +70 -0
  82. package/skills/publish-social-posts/SKILL.md +70 -0
  83. package/skills/rate-anomalies/SKILL.md +62 -0
  84. package/skills/scrape-ads/SKILL.md +49 -0
  85. package/skills/stamp-transactions/SKILL.md +62 -0
  86. package/skills/upload-income-statements/SKILL.md +54 -0
  87. package/skills/upload-netsuite/SKILL.md +56 -0
  88. package/skills/upload-r1/SKILL.md +45 -0
  89. package/supabase/.temp/cli-latest +1 -0
  90. package/supabase/migrations/.gitkeep +0 -0
  91. package/supabase/migrations/20250305000001_create_agent_configs.sql +36 -0
  92. package/supabase/migrations/20260305111300_create_cli_config_registry.sql +22 -0
  93. package/supabase/migrations/20260306195000_create_kanban_tables.sql +97 -0
  94. package/tests/config-command-smoke.test.ts +395 -0
  95. package/tests/config-registry.test.ts +173 -0
  96. package/tsconfig.json +19 -0
  97. package/agents/profiles.json +0 -5
  98. package/dist/bin/optimal.d.ts +0 -2
  99. package/dist/bin/optimal.js +0 -1590
  100. package/dist/lib/assets/index.d.ts +0 -79
  101. package/dist/lib/assets/index.js +0 -153
  102. package/dist/lib/assets.d.ts +0 -20
  103. package/dist/lib/assets.js +0 -112
  104. package/dist/lib/auth/index.d.ts +0 -83
  105. package/dist/lib/auth/index.js +0 -146
  106. package/dist/lib/board/index.d.ts +0 -39
  107. package/dist/lib/board/index.js +0 -285
  108. package/dist/lib/board/types.d.ts +0 -111
  109. package/dist/lib/board/types.js +0 -1
  110. package/dist/lib/bot/claim.d.ts +0 -3
  111. package/dist/lib/bot/claim.js +0 -20
  112. package/dist/lib/bot/coordinator.d.ts +0 -27
  113. package/dist/lib/bot/coordinator.js +0 -178
  114. package/dist/lib/bot/heartbeat.d.ts +0 -6
  115. package/dist/lib/bot/heartbeat.js +0 -30
  116. package/dist/lib/bot/index.d.ts +0 -9
  117. package/dist/lib/bot/index.js +0 -6
  118. package/dist/lib/bot/protocol.d.ts +0 -12
  119. package/dist/lib/bot/protocol.js +0 -74
  120. package/dist/lib/bot/reporter.d.ts +0 -3
  121. package/dist/lib/bot/reporter.js +0 -27
  122. package/dist/lib/bot/skills.d.ts +0 -26
  123. package/dist/lib/bot/skills.js +0 -69
  124. package/dist/lib/budget/projections.d.ts +0 -115
  125. package/dist/lib/budget/projections.js +0 -384
  126. package/dist/lib/budget/scenarios.d.ts +0 -93
  127. package/dist/lib/budget/scenarios.js +0 -214
  128. package/dist/lib/cms/publish-blog.d.ts +0 -62
  129. package/dist/lib/cms/publish-blog.js +0 -74
  130. package/dist/lib/cms/strapi-client.d.ts +0 -123
  131. package/dist/lib/cms/strapi-client.js +0 -213
  132. package/dist/lib/config/registry.d.ts +0 -17
  133. package/dist/lib/config/registry.js +0 -182
  134. package/dist/lib/config/schema.d.ts +0 -31
  135. package/dist/lib/config/schema.js +0 -25
  136. package/dist/lib/config.d.ts +0 -55
  137. package/dist/lib/config.js +0 -206
  138. package/dist/lib/errors.d.ts +0 -25
  139. package/dist/lib/errors.js +0 -91
  140. package/dist/lib/format.d.ts +0 -28
  141. package/dist/lib/format.js +0 -98
  142. package/dist/lib/infra/deploy.d.ts +0 -29
  143. package/dist/lib/infra/deploy.js +0 -58
  144. package/dist/lib/infra/migrate.d.ts +0 -34
  145. package/dist/lib/infra/migrate.js +0 -103
  146. package/dist/lib/newsletter/distribute.d.ts +0 -52
  147. package/dist/lib/newsletter/distribute.js +0 -193
  148. package/dist/lib/newsletter/generate-insurance.js +0 -36
  149. package/dist/lib/newsletter/generate.d.ts +0 -104
  150. package/dist/lib/newsletter/generate.js +0 -571
  151. package/dist/lib/returnpro/anomalies.d.ts +0 -64
  152. package/dist/lib/returnpro/anomalies.js +0 -166
  153. package/dist/lib/returnpro/audit.d.ts +0 -32
  154. package/dist/lib/returnpro/audit.js +0 -147
  155. package/dist/lib/returnpro/diagnose.d.ts +0 -52
  156. package/dist/lib/returnpro/diagnose.js +0 -281
  157. package/dist/lib/returnpro/kpis.d.ts +0 -32
  158. package/dist/lib/returnpro/kpis.js +0 -192
  159. package/dist/lib/returnpro/templates.d.ts +0 -48
  160. package/dist/lib/returnpro/templates.js +0 -229
  161. package/dist/lib/returnpro/upload-income.d.ts +0 -25
  162. package/dist/lib/returnpro/upload-income.js +0 -235
  163. package/dist/lib/returnpro/upload-netsuite.d.ts +0 -37
  164. package/dist/lib/returnpro/upload-netsuite.js +0 -566
  165. package/dist/lib/returnpro/upload-r1.d.ts +0 -48
  166. package/dist/lib/returnpro/upload-r1.js +0 -398
  167. package/dist/lib/returnpro/validate.d.ts +0 -37
  168. package/dist/lib/returnpro/validate.js +0 -124
  169. package/dist/lib/social/meta.d.ts +0 -90
  170. package/dist/lib/social/meta.js +0 -160
  171. package/dist/lib/social/post-generator.d.ts +0 -83
  172. package/dist/lib/social/post-generator.js +0 -333
  173. package/dist/lib/social/publish.d.ts +0 -66
  174. package/dist/lib/social/publish.js +0 -226
  175. package/dist/lib/social/scraper.d.ts +0 -67
  176. package/dist/lib/social/scraper.js +0 -361
  177. package/dist/lib/supabase.d.ts +0 -4
  178. package/dist/lib/supabase.js +0 -20
  179. package/dist/lib/transactions/delete-batch.d.ts +0 -60
  180. package/dist/lib/transactions/delete-batch.js +0 -203
  181. package/dist/lib/transactions/ingest.d.ts +0 -43
  182. package/dist/lib/transactions/ingest.js +0 -555
  183. package/dist/lib/transactions/stamp.d.ts +0 -51
  184. package/dist/lib/transactions/stamp.js +0 -524
  185. 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
+ }