-
Notifications
You must be signed in to change notification settings - Fork 82
Expand file tree
/
Copy pathdocument-create.ts
More file actions
358 lines (321 loc) · 9.91 KB
/
Copy pathdocument-create.ts
File metadata and controls
358 lines (321 loc) · 9.91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
import { Command } from "@cliffy/command"
import { Input, Select } from "@cliffy/prompt"
import { gql } from "../../__codegen__/gql.ts"
import { getGraphQLClient } from "../../utils/graphql.ts"
import { resolveProjectId } from "../../utils/linear.ts"
import { getEditor, openEditor } from "../../utils/editor.ts"
import { readIdsFromStdin } from "../../utils/bulk.ts"
import {
CliError,
handleError,
NotFoundError,
ValidationError,
} from "../../utils/errors.ts"
/**
* Read content from stdin if available (piped input, with timeout)
*/
async function readContentFromStdin(): Promise<string | undefined> {
// Check if stdin has data (not a TTY)
if (Deno.stdin.isTerminal()) {
return undefined
}
try {
// Use timeout to avoid hanging when stdin is not a terminal but has no data
// (e.g., in test subprocess environments)
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("stdin timeout")), 100)
})
const lines = await Promise.race([readIdsFromStdin(), timeoutPromise])
// Join back with newlines since it's content, not IDs
const content = lines.join("\n")
return content.length > 0 ? content : undefined
} catch {
return undefined
}
}
export const createCommand = new Command()
.name("create")
.description("Create a new document")
.alias("c")
.option("-t, --title <title:string>", "Document title (required)")
.option("-c, --content <content:string>", "Markdown content (inline)")
.option("-f, --content-file <path:string>", "Read content from file")
.option(
"--project <project:string>",
"Attach to project (UUID, slug ID, or name)",
)
.option("--issue <issue:string>", "Attach to issue (identifier like TC-123)")
.option("--icon <icon:string>", "Document icon (emoji)")
.option("-i, --interactive", "Interactive mode with prompts")
.action(
async ({
title,
content,
contentFile,
project,
issue,
icon,
interactive,
}) => {
try {
const client = getGraphQLClient()
// Determine if we should use interactive mode
let useInteractive = interactive && Deno.stdout.isTerminal()
// If no title and not interactive, check if we should enter interactive mode
const noFlagsProvided = !title && !content && !contentFile &&
!project &&
!issue && !icon
if (noFlagsProvided && Deno.stdout.isTerminal()) {
useInteractive = true
}
// Interactive mode
if (useInteractive) {
const result = await promptInteractiveCreate()
if (!result.title) {
throw new ValidationError("Title is required")
}
const input: Record<string, string | undefined> = {
title: result.title,
content: result.content,
icon: result.icon,
projectId: result.projectId,
issueId: result.issueId,
}
// Remove undefined values
Object.keys(input).forEach((key) => {
if (input[key] === undefined) {
delete input[key]
}
})
await createDocument(client, input)
return
}
// Non-interactive mode requires title
if (!title) {
throw new ValidationError("Title is required", {
suggestion: "Use --title or run with -i for interactive mode.",
})
}
// Resolve content from various sources
let finalContent: string | undefined
if (content) {
// Content provided inline via --content
finalContent = content
} else if (contentFile) {
// Content from file via --content-file
try {
finalContent = await Deno.readTextFile(contentFile)
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
throw new NotFoundError("File", contentFile)
}
throw new CliError(
`Failed to read content file: ${
error instanceof Error ? error.message : String(error)
}`,
{ cause: error },
)
}
} else if (!Deno.stdin.isTerminal()) {
// Try reading from stdin if piped
const stdinContent = await readContentFromStdin()
if (stdinContent) {
finalContent = stdinContent
}
} else if (Deno.stdout.isTerminal()) {
// No content provided, open editor
console.log("Opening editor for document content...")
finalContent = await openEditor()
if (!finalContent) {
console.log(
"No content entered. Creating document without content.",
)
}
}
// Resolve project ID if provided
let projectId: string | undefined
if (project) {
projectId = await resolveProjectId(project)
}
// Resolve issue ID if provided
let issueId: string | undefined
if (issue) {
issueId = await resolveIssueId(client, issue)
if (!issueId) {
throw new NotFoundError("Issue", issue, {
suggestion: "Provide a valid issue identifier (e.g., TC-123).",
})
}
}
// Build input
const input: Record<string, string | undefined> = {
title,
content: finalContent,
icon,
projectId,
issueId,
}
// Remove undefined values
Object.keys(input).forEach((key) => {
if (input[key] === undefined) {
delete input[key]
}
})
await createDocument(client, input)
} catch (error) {
handleError(error, "Failed to create document")
}
},
)
async function promptInteractiveCreate(): Promise<{
title?: string
content?: string
icon?: string
projectId?: string
issueId?: string
}> {
// Prompt for title
const title = await Input.prompt({
message: "Document title",
minLength: 1,
})
// Prompt for description entry method
const editorName = await getEditor()
const editorDisplayName = editorName ? editorName.split("/").pop() : null
const contentMethod = await Select.prompt({
message: "How would you like to enter content?",
options: [
{ name: "Skip (no content)", value: "skip" },
{ name: "Enter inline", value: "inline" },
...(editorDisplayName
? [{ name: `Open ${editorDisplayName}`, value: "editor" }]
: []),
{ name: "Read from file", value: "file" },
],
default: "skip",
})
let content: string | undefined
if (contentMethod === "inline") {
const inlineContent = await Input.prompt({
message: "Content (markdown)",
default: "",
})
content = inlineContent.trim() || undefined
} else if (contentMethod === "editor" && editorDisplayName) {
console.log(`Opening ${editorDisplayName}...`)
content = await openEditor()
if (content) {
console.log(`Content entered (${content.length} characters)`)
}
} else if (contentMethod === "file") {
const filePath = await Input.prompt({
message: "File path",
})
try {
content = await Deno.readTextFile(filePath)
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
throw new NotFoundError("File", filePath)
}
throw new CliError(
`Failed to read file: ${
error instanceof Error ? error.message : String(error)
}`,
{ cause: error },
)
}
}
// Prompt for icon
const icon = await Input.prompt({
message: "Icon (emoji, leave blank for none)",
default: "",
})
// Ask about attachment
const attachTo = await Select.prompt({
message: "Attach document to",
options: [
{ name: "Nothing (workspace document)", value: "none" },
{ name: "Project", value: "project" },
{ name: "Issue", value: "issue" },
],
default: "none",
})
let projectId: string | undefined
let issueId: string | undefined
if (attachTo === "project") {
const projectInput = await Input.prompt({
message: "Project (UUID, slug ID, or name)",
})
projectId = await resolveProjectId(projectInput)
} else if (attachTo === "issue") {
const issueInput = await Input.prompt({
message: "Issue identifier (e.g., TC-123)",
})
const client = getGraphQLClient()
issueId = await resolveIssueId(client, issueInput)
if (!issueId) {
throw new NotFoundError("Issue", issueInput, {
suggestion: "Provide a valid issue identifier (e.g., TC-123).",
})
}
}
return {
title,
content,
icon: icon.trim() || undefined,
projectId,
issueId,
}
}
async function resolveIssueId(
// deno-lint-ignore no-explicit-any
client: any,
issueIdentifier: string,
): Promise<string | undefined> {
const issueQuery = gql(`
query GetIssueForDocument($id: String!) {
issue(id: $id) {
id
identifier
}
}
`)
try {
const result = await client.request(issueQuery, { id: issueIdentifier })
if (result.issue) {
return result.issue.id
}
} catch {
// Issue not found
}
return undefined
}
async function createDocument(
// deno-lint-ignore no-explicit-any
client: any,
input: Record<string, string | undefined>,
): Promise<void> {
const createMutation = gql(`
mutation CreateDocument($input: DocumentCreateInput!) {
documentCreate(input: $input) {
success
document {
id
slugId
title
url
}
}
}
`)
const result = await client.request(createMutation, { input })
if (!result.documentCreate.success) {
throw new CliError("Document creation failed")
}
const document = result.documentCreate.document
if (!document) {
throw new CliError("Document creation failed - no document returned")
}
console.log(`✓ Created document: ${document.title}`)
console.log(document.url)
}