prjct-cli 0.20.1 → 0.22.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/core/integrations/notion/client.ts +413 -0
- package/core/integrations/notion/index.ts +46 -0
- package/core/integrations/notion/setup.ts +235 -0
- package/core/integrations/notion/sync.ts +818 -0
- package/core/integrations/notion/templates.ts +246 -0
- package/core/plugin/builtin/notion.ts +178 -0
- package/core/types/config.ts +4 -0
- package/core/types/index.ts +9 -0
- package/core/types/integrations.ts +57 -0
- package/core/types/storage.ts +8 -0
- package/core/types/task.ts +4 -0
- package/package.json +1 -1
- package/templates/commands/init.md +43 -0
- package/templates/mcp-config.json +28 -0
- package/templates/skills/notion-push.md +116 -0
- package/templates/skills/notion-setup.md +199 -0
- package/templates/skills/notion-sync.md +290 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notion Client
|
|
3
|
+
* Wrapper for Notion MCP tools with fallback to direct API.
|
|
4
|
+
*
|
|
5
|
+
* Uses MCP tools when available (via Claude), falls back to fetch for CLI usage.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { NotionIntegrationConfig } from '../../types/integrations'
|
|
9
|
+
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// Types
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
export interface NotionDatabase {
|
|
15
|
+
id: string
|
|
16
|
+
title: string
|
|
17
|
+
url: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface NotionPage {
|
|
21
|
+
id: string
|
|
22
|
+
url: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface NotionProperty {
|
|
26
|
+
type: string
|
|
27
|
+
[key: string]: unknown
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface NotionDatabaseSchema {
|
|
31
|
+
title: string
|
|
32
|
+
properties: Record<string, NotionProperty>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface NotionQueryFilter {
|
|
36
|
+
property: string
|
|
37
|
+
rich_text?: { equals: string }
|
|
38
|
+
title?: { equals: string }
|
|
39
|
+
date?: { equals: string }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Notion API response types
|
|
43
|
+
interface NotionApiResponse {
|
|
44
|
+
id?: string
|
|
45
|
+
url?: string
|
|
46
|
+
bot?: {
|
|
47
|
+
workspace_name?: string
|
|
48
|
+
owner?: {
|
|
49
|
+
workspace?: {
|
|
50
|
+
id?: string
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
results?: Array<{ id: string; properties: Record<string, unknown> }>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// =============================================================================
|
|
58
|
+
// Notion Client Class
|
|
59
|
+
// =============================================================================
|
|
60
|
+
|
|
61
|
+
export class NotionClient {
|
|
62
|
+
private config: NotionIntegrationConfig | null = null
|
|
63
|
+
private apiToken: string | null = null
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Initialize client with config
|
|
67
|
+
*/
|
|
68
|
+
initialize(config: NotionIntegrationConfig, apiToken?: string): void {
|
|
69
|
+
this.config = config
|
|
70
|
+
this.apiToken = apiToken || process.env.NOTION_TOKEN || null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check if client is ready
|
|
75
|
+
*/
|
|
76
|
+
isReady(): boolean {
|
|
77
|
+
return this.config?.enabled === true && this.apiToken !== null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get workspace info
|
|
82
|
+
*/
|
|
83
|
+
async getWorkspace(): Promise<{ id: string; name: string } | null> {
|
|
84
|
+
if (!this.apiToken) return null
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const response = await this.apiRequest('/users/me')
|
|
88
|
+
if (response.bot?.workspace_name) {
|
|
89
|
+
return {
|
|
90
|
+
id: response.bot.owner?.workspace?.id || 'unknown',
|
|
91
|
+
name: response.bot.workspace_name,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null
|
|
95
|
+
} catch {
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Create a database in Notion
|
|
102
|
+
*/
|
|
103
|
+
async createDatabase(
|
|
104
|
+
parentPageId: string,
|
|
105
|
+
schema: NotionDatabaseSchema
|
|
106
|
+
): Promise<NotionDatabase | null> {
|
|
107
|
+
try {
|
|
108
|
+
const response = await this.apiRequest('/databases', 'POST', {
|
|
109
|
+
parent: { type: 'page_id', page_id: parentPageId },
|
|
110
|
+
title: [{ type: 'text', text: { content: schema.title } }],
|
|
111
|
+
properties: schema.properties,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
if (!response.id || !response.url) {
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
id: response.id,
|
|
120
|
+
title: schema.title,
|
|
121
|
+
url: response.url,
|
|
122
|
+
}
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.error('[notion] Failed to create database:', (error as Error).message)
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Create a page in a database
|
|
131
|
+
*/
|
|
132
|
+
async createPage(
|
|
133
|
+
databaseId: string,
|
|
134
|
+
properties: Record<string, unknown>
|
|
135
|
+
): Promise<NotionPage | null> {
|
|
136
|
+
try {
|
|
137
|
+
const response = await this.apiRequest('/pages', 'POST', {
|
|
138
|
+
parent: { type: 'database_id', database_id: databaseId },
|
|
139
|
+
properties,
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
if (!response.id || !response.url) {
|
|
143
|
+
return null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
id: response.id,
|
|
148
|
+
url: response.url,
|
|
149
|
+
}
|
|
150
|
+
} catch (error) {
|
|
151
|
+
console.error('[notion] Failed to create page:', (error as Error).message)
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Update a page
|
|
158
|
+
*/
|
|
159
|
+
async updatePage(
|
|
160
|
+
pageId: string,
|
|
161
|
+
properties: Record<string, unknown>
|
|
162
|
+
): Promise<NotionPage | null> {
|
|
163
|
+
try {
|
|
164
|
+
const response = await this.apiRequest(`/pages/${pageId}`, 'PATCH', {
|
|
165
|
+
properties,
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
if (!response.id || !response.url) {
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
id: response.id,
|
|
174
|
+
url: response.url,
|
|
175
|
+
}
|
|
176
|
+
} catch (error) {
|
|
177
|
+
console.error('[notion] Failed to update page:', (error as Error).message)
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Update page content (for dashboard)
|
|
184
|
+
*/
|
|
185
|
+
async updatePageContent(pageId: string, content: string): Promise<boolean> {
|
|
186
|
+
try {
|
|
187
|
+
// Convert markdown content to Notion blocks
|
|
188
|
+
const blocks = this.markdownToBlocks(content)
|
|
189
|
+
|
|
190
|
+
// Clear existing content and add new blocks
|
|
191
|
+
// First, get existing blocks to delete them
|
|
192
|
+
const existingBlocks = await this.apiRequest(
|
|
193
|
+
`/blocks/${pageId}/children`,
|
|
194
|
+
'GET'
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
// Delete existing blocks
|
|
198
|
+
for (const block of existingBlocks.results || []) {
|
|
199
|
+
await this.apiRequest(`/blocks/${block.id}`, 'DELETE')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Add new blocks
|
|
203
|
+
await this.apiRequest(`/blocks/${pageId}/children`, 'PATCH', {
|
|
204
|
+
children: blocks,
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
return true
|
|
208
|
+
} catch (error) {
|
|
209
|
+
console.error('[notion] Failed to update page content:', (error as Error).message)
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Convert simple markdown to Notion blocks
|
|
216
|
+
*/
|
|
217
|
+
private markdownToBlocks(content: string): unknown[] {
|
|
218
|
+
const blocks: unknown[] = []
|
|
219
|
+
const lines = content.split('\n')
|
|
220
|
+
|
|
221
|
+
for (const line of lines) {
|
|
222
|
+
if (line.startsWith('# ')) {
|
|
223
|
+
blocks.push({
|
|
224
|
+
type: 'heading_1',
|
|
225
|
+
heading_1: {
|
|
226
|
+
rich_text: [{ type: 'text', text: { content: line.slice(2) } }],
|
|
227
|
+
},
|
|
228
|
+
})
|
|
229
|
+
} else if (line.startsWith('## ')) {
|
|
230
|
+
blocks.push({
|
|
231
|
+
type: 'heading_2',
|
|
232
|
+
heading_2: {
|
|
233
|
+
rich_text: [{ type: 'text', text: { content: line.slice(3) } }],
|
|
234
|
+
},
|
|
235
|
+
})
|
|
236
|
+
} else if (line.startsWith('- ')) {
|
|
237
|
+
blocks.push({
|
|
238
|
+
type: 'bulleted_list_item',
|
|
239
|
+
bulleted_list_item: {
|
|
240
|
+
rich_text: [{ type: 'text', text: { content: line.slice(2) } }],
|
|
241
|
+
},
|
|
242
|
+
})
|
|
243
|
+
} else if (line.startsWith('|') && line.includes('|')) {
|
|
244
|
+
// Table row - skip header separator
|
|
245
|
+
if (!line.match(/^\|[-|]+\|$/)) {
|
|
246
|
+
const cells = line.split('|').filter(Boolean).map((c) => c.trim())
|
|
247
|
+
if (cells.length > 0) {
|
|
248
|
+
blocks.push({
|
|
249
|
+
type: 'paragraph',
|
|
250
|
+
paragraph: {
|
|
251
|
+
rich_text: [{ type: 'text', text: { content: cells.join(' | ') } }],
|
|
252
|
+
},
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
} else if (line.trim() === '---') {
|
|
257
|
+
blocks.push({ type: 'divider', divider: {} })
|
|
258
|
+
} else if (line.trim()) {
|
|
259
|
+
blocks.push({
|
|
260
|
+
type: 'paragraph',
|
|
261
|
+
paragraph: {
|
|
262
|
+
rich_text: [{ type: 'text', text: { content: line } }],
|
|
263
|
+
},
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return blocks
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Query a database
|
|
273
|
+
*/
|
|
274
|
+
async queryDatabase(
|
|
275
|
+
databaseId: string,
|
|
276
|
+
filter?: NotionQueryFilter
|
|
277
|
+
): Promise<Array<{ id: string; properties: Record<string, unknown> }>> {
|
|
278
|
+
try {
|
|
279
|
+
const body: Record<string, unknown> = {}
|
|
280
|
+
if (filter) {
|
|
281
|
+
body.filter = filter
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const response = await this.apiRequest(
|
|
285
|
+
`/databases/${databaseId}/query`,
|
|
286
|
+
'POST',
|
|
287
|
+
body
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
return (response.results || []).map(
|
|
291
|
+
(page: { id: string; properties: Record<string, unknown> }) => ({
|
|
292
|
+
id: page.id,
|
|
293
|
+
properties: page.properties,
|
|
294
|
+
})
|
|
295
|
+
)
|
|
296
|
+
} catch (error) {
|
|
297
|
+
console.error('[notion] Failed to query database:', (error as Error).message)
|
|
298
|
+
return []
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Find page by name (for upsert)
|
|
304
|
+
* Note: Since each project has its own database, we don't need to filter by project
|
|
305
|
+
*/
|
|
306
|
+
async findPageByProjectAndName(
|
|
307
|
+
databaseId: string,
|
|
308
|
+
_projectId: string,
|
|
309
|
+
name: string
|
|
310
|
+
): Promise<string | null> {
|
|
311
|
+
try {
|
|
312
|
+
const response = await this.apiRequest(
|
|
313
|
+
`/databases/${databaseId}/query`,
|
|
314
|
+
'POST',
|
|
315
|
+
{
|
|
316
|
+
filter: {
|
|
317
|
+
or: [
|
|
318
|
+
{ property: 'Name', title: { equals: name } },
|
|
319
|
+
{ property: 'Idea', title: { equals: name } },
|
|
320
|
+
],
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
const results = response.results || []
|
|
326
|
+
return results.length > 0 ? results[0].id : null
|
|
327
|
+
} catch {
|
|
328
|
+
return null
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Create a page (for dashboard)
|
|
334
|
+
*/
|
|
335
|
+
async createDashboardPage(
|
|
336
|
+
parentPageId: string,
|
|
337
|
+
title: string,
|
|
338
|
+
content: string
|
|
339
|
+
): Promise<NotionPage | null> {
|
|
340
|
+
try {
|
|
341
|
+
const response = await this.apiRequest('/pages', 'POST', {
|
|
342
|
+
parent: { type: 'page_id', page_id: parentPageId },
|
|
343
|
+
properties: {
|
|
344
|
+
title: [{ type: 'text', text: { content: title } }],
|
|
345
|
+
},
|
|
346
|
+
children: [
|
|
347
|
+
{
|
|
348
|
+
object: 'block',
|
|
349
|
+
type: 'paragraph',
|
|
350
|
+
paragraph: {
|
|
351
|
+
rich_text: [{ type: 'text', text: { content } }],
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
if (!response.id || !response.url) {
|
|
358
|
+
return null
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
id: response.id,
|
|
363
|
+
url: response.url,
|
|
364
|
+
}
|
|
365
|
+
} catch (error) {
|
|
366
|
+
console.error('[notion] Failed to create page:', (error as Error).message)
|
|
367
|
+
return null
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Make API request to Notion
|
|
373
|
+
*/
|
|
374
|
+
private async apiRequest(
|
|
375
|
+
endpoint: string,
|
|
376
|
+
method = 'GET',
|
|
377
|
+
body?: Record<string, unknown>
|
|
378
|
+
): Promise<NotionApiResponse> {
|
|
379
|
+
if (!this.apiToken) {
|
|
380
|
+
throw new Error('Notion API token not configured')
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const url = `https://api.notion.com/v1${endpoint}`
|
|
384
|
+
const headers: Record<string, string> = {
|
|
385
|
+
Authorization: `Bearer ${this.apiToken}`,
|
|
386
|
+
'Content-Type': 'application/json',
|
|
387
|
+
'Notion-Version': '2022-06-28',
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const options: RequestInit = {
|
|
391
|
+
method,
|
|
392
|
+
headers,
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (body) {
|
|
396
|
+
options.body = JSON.stringify(body)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const response = await fetch(url, options)
|
|
400
|
+
|
|
401
|
+
if (!response.ok) {
|
|
402
|
+
const errorData = (await response.json().catch(() => ({}))) as { message?: string }
|
|
403
|
+
throw new Error(
|
|
404
|
+
`Notion API error: ${response.status} - ${errorData.message || 'Unknown error'}`
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return (await response.json()) as NotionApiResponse
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Singleton instance
|
|
413
|
+
export const notionClient = new NotionClient()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notion Integration Module
|
|
3
|
+
* Optional integration with Notion for syncing prjct data.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Client
|
|
7
|
+
export { NotionClient, notionClient } from './client'
|
|
8
|
+
export type {
|
|
9
|
+
NotionDatabase,
|
|
10
|
+
NotionPage,
|
|
11
|
+
NotionProperty,
|
|
12
|
+
NotionDatabaseSchema,
|
|
13
|
+
NotionQueryFilter,
|
|
14
|
+
} from './client'
|
|
15
|
+
|
|
16
|
+
// Templates
|
|
17
|
+
export {
|
|
18
|
+
SHIPPED_DATABASE_SCHEMA,
|
|
19
|
+
ROADMAP_DATABASE_SCHEMA,
|
|
20
|
+
IDEAS_DATABASE_SCHEMA,
|
|
21
|
+
TASKS_DATABASE_SCHEMA,
|
|
22
|
+
ALL_DATABASE_SCHEMAS,
|
|
23
|
+
getDashboardContent,
|
|
24
|
+
} from './templates'
|
|
25
|
+
|
|
26
|
+
// Sync
|
|
27
|
+
export {
|
|
28
|
+
syncShippedFeature,
|
|
29
|
+
syncIdea,
|
|
30
|
+
fullSync,
|
|
31
|
+
pullShippedFeatures,
|
|
32
|
+
pullIdeas,
|
|
33
|
+
bidirectionalSync,
|
|
34
|
+
} from './sync'
|
|
35
|
+
export type { SyncResult, PullResult } from './sync'
|
|
36
|
+
|
|
37
|
+
// Setup
|
|
38
|
+
export {
|
|
39
|
+
checkNotionMCPAvailable,
|
|
40
|
+
validateToken,
|
|
41
|
+
createDatabases,
|
|
42
|
+
setupNotion,
|
|
43
|
+
getSetupInstructions,
|
|
44
|
+
parseNotionPageUrl,
|
|
45
|
+
} from './setup'
|
|
46
|
+
export type { SetupResult, ValidationResult } from './setup'
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notion Setup Flow
|
|
3
|
+
* Interactive setup for Notion integration.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { NotionIntegrationConfig } from '../../types/integrations'
|
|
7
|
+
import { DEFAULT_NOTION_CONFIG } from '../../types/integrations'
|
|
8
|
+
import { notionClient } from './client'
|
|
9
|
+
import {
|
|
10
|
+
ALL_DATABASE_SCHEMAS,
|
|
11
|
+
getDashboardContent,
|
|
12
|
+
} from './templates'
|
|
13
|
+
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// Types
|
|
16
|
+
// =============================================================================
|
|
17
|
+
|
|
18
|
+
export interface SetupResult {
|
|
19
|
+
success: boolean
|
|
20
|
+
config?: NotionIntegrationConfig
|
|
21
|
+
error?: string
|
|
22
|
+
message?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ValidationResult {
|
|
26
|
+
valid: boolean
|
|
27
|
+
workspaceName?: string
|
|
28
|
+
error?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// =============================================================================
|
|
32
|
+
// Setup Functions
|
|
33
|
+
// =============================================================================
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check if Notion MCP is available
|
|
37
|
+
* Returns true if the MCP server is configured
|
|
38
|
+
*/
|
|
39
|
+
export function checkNotionMCPAvailable(): boolean {
|
|
40
|
+
// Check if running in Claude environment with MCP
|
|
41
|
+
if (typeof globalThis !== 'undefined') {
|
|
42
|
+
const g = globalThis as Record<string, unknown>
|
|
43
|
+
if (g.mcp || g.notion || process.env.NOTION_TOKEN) {
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return !!process.env.NOTION_TOKEN
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Validate Notion API token
|
|
52
|
+
*/
|
|
53
|
+
export async function validateToken(token: string): Promise<ValidationResult> {
|
|
54
|
+
try {
|
|
55
|
+
// Temporarily initialize client with token
|
|
56
|
+
const tempConfig: NotionIntegrationConfig = {
|
|
57
|
+
...DEFAULT_NOTION_CONFIG,
|
|
58
|
+
enabled: true,
|
|
59
|
+
}
|
|
60
|
+
notionClient.initialize(tempConfig, token)
|
|
61
|
+
|
|
62
|
+
const workspace = await notionClient.getWorkspace()
|
|
63
|
+
if (workspace) {
|
|
64
|
+
return {
|
|
65
|
+
valid: true,
|
|
66
|
+
workspaceName: workspace.name,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
valid: false,
|
|
72
|
+
error: 'Could not connect to Notion workspace',
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return {
|
|
76
|
+
valid: false,
|
|
77
|
+
error: (error as Error).message,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Create all prjct databases in Notion
|
|
84
|
+
*/
|
|
85
|
+
export async function createDatabases(
|
|
86
|
+
parentPageId: string,
|
|
87
|
+
projectName: string
|
|
88
|
+
): Promise<{
|
|
89
|
+
success: boolean
|
|
90
|
+
databases: NotionIntegrationConfig['databases']
|
|
91
|
+
dashboardPageId?: string
|
|
92
|
+
error?: string
|
|
93
|
+
}> {
|
|
94
|
+
const databases: NotionIntegrationConfig['databases'] = {}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
// Create each database with project-specific names
|
|
98
|
+
for (const [key, schema] of Object.entries(ALL_DATABASE_SCHEMAS)) {
|
|
99
|
+
// Override title with project name prefix
|
|
100
|
+
const projectSchema = {
|
|
101
|
+
...schema,
|
|
102
|
+
title: schema.title.replace('prjct:', `${projectName}:`),
|
|
103
|
+
}
|
|
104
|
+
const db = await notionClient.createDatabase(parentPageId, projectSchema)
|
|
105
|
+
if (db) {
|
|
106
|
+
databases[key as keyof typeof databases] = db.id
|
|
107
|
+
} else {
|
|
108
|
+
return {
|
|
109
|
+
success: false,
|
|
110
|
+
databases,
|
|
111
|
+
error: `Failed to create ${projectSchema.title} database`,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Create dashboard page
|
|
117
|
+
const dashboardContent = getDashboardContent(projectName, databases)
|
|
118
|
+
const dashboard = await notionClient.createDashboardPage(
|
|
119
|
+
parentPageId,
|
|
120
|
+
`${projectName} Dashboard`,
|
|
121
|
+
dashboardContent
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
success: true,
|
|
126
|
+
databases,
|
|
127
|
+
dashboardPageId: dashboard?.id,
|
|
128
|
+
}
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return {
|
|
131
|
+
success: false,
|
|
132
|
+
databases,
|
|
133
|
+
error: (error as Error).message,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Full setup flow
|
|
140
|
+
* Called by /p:init or /p:notion setup
|
|
141
|
+
*/
|
|
142
|
+
export async function setupNotion(params: {
|
|
143
|
+
token: string
|
|
144
|
+
parentPageId: string
|
|
145
|
+
projectId: string
|
|
146
|
+
projectName: string
|
|
147
|
+
}): Promise<SetupResult> {
|
|
148
|
+
const { token, parentPageId, projectId, projectName } = params
|
|
149
|
+
|
|
150
|
+
// Step 1: Validate token
|
|
151
|
+
const validation = await validateToken(token)
|
|
152
|
+
if (!validation.valid) {
|
|
153
|
+
return {
|
|
154
|
+
success: false,
|
|
155
|
+
error: validation.error || 'Invalid token',
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Step 2: Initialize client
|
|
160
|
+
const config: NotionIntegrationConfig = {
|
|
161
|
+
...DEFAULT_NOTION_CONFIG,
|
|
162
|
+
enabled: true,
|
|
163
|
+
workspaceName: validation.workspaceName,
|
|
164
|
+
}
|
|
165
|
+
notionClient.initialize(config, token)
|
|
166
|
+
|
|
167
|
+
// Step 3: Create databases
|
|
168
|
+
const result = await createDatabases(parentPageId, projectName)
|
|
169
|
+
if (!result.success) {
|
|
170
|
+
return {
|
|
171
|
+
success: false,
|
|
172
|
+
error: result.error,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Step 4: Build final config
|
|
177
|
+
const finalConfig: NotionIntegrationConfig = {
|
|
178
|
+
enabled: true,
|
|
179
|
+
workspaceName: validation.workspaceName,
|
|
180
|
+
databases: result.databases,
|
|
181
|
+
dashboardPageId: result.dashboardPageId,
|
|
182
|
+
syncOn: {
|
|
183
|
+
ship: true,
|
|
184
|
+
done: false,
|
|
185
|
+
idea: true,
|
|
186
|
+
},
|
|
187
|
+
setupAt: new Date().toISOString(),
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
success: true,
|
|
192
|
+
config: finalConfig,
|
|
193
|
+
message: `Notion connected to "${validation.workspaceName}". Created 4 databases.`,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get setup instructions for user
|
|
199
|
+
*/
|
|
200
|
+
export function getSetupInstructions(): string[] {
|
|
201
|
+
return [
|
|
202
|
+
'1. Go to https://www.notion.so/my-integrations',
|
|
203
|
+
'2. Click "New integration"',
|
|
204
|
+
'3. Name it "prjct-cli" and select your workspace',
|
|
205
|
+
'4. Copy the Internal Integration Secret (starts with ntn_)',
|
|
206
|
+
'5. Share a Notion page with the integration (this will be the parent)',
|
|
207
|
+
'6. Copy the page ID from the URL (the 32-character string)',
|
|
208
|
+
]
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Parse Notion page URL to get page ID
|
|
213
|
+
*/
|
|
214
|
+
export function parseNotionPageUrl(url: string): string | null {
|
|
215
|
+
// Handle various Notion URL formats
|
|
216
|
+
// https://www.notion.so/workspace/Page-Title-abc123def456
|
|
217
|
+
// https://notion.so/abc123def456
|
|
218
|
+
// abc123def456 (just the ID)
|
|
219
|
+
|
|
220
|
+
const cleanUrl = url.trim()
|
|
221
|
+
|
|
222
|
+
// If it's already a 32-char hex string
|
|
223
|
+
if (/^[a-f0-9]{32}$/i.test(cleanUrl)) {
|
|
224
|
+
return cleanUrl
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// If it's a UUID format (with dashes)
|
|
228
|
+
if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(cleanUrl)) {
|
|
229
|
+
return cleanUrl.replace(/-/g, '')
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Try to extract from URL
|
|
233
|
+
const match = cleanUrl.match(/([a-f0-9]{32})/i)
|
|
234
|
+
return match ? match[1] : null
|
|
235
|
+
}
|