@debonet/idd 0.0.0-stage → 0.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.
@@ -0,0 +1,847 @@
1
+ // @tested-by test/idd-page.test.js
2
+ // @implements s-4lt6mo@2440660
3
+ // @implements s-cg2j10@e28309f
4
+ // @implements s-dal2u7@29b9549
5
+ // @implements s-s1eg2z@e4283c0
6
+ // @implements s-hsle36@555b1d8
7
+ // @implements s-7wfrt9@cf88f5c
8
+ // @implements s-854kw7@d393719
9
+ // @implements s-y1y75p@326b683
10
+ // @implements s-zrui8r@5a4422e
11
+ // @implements s-zqk718@d5acf2d
12
+ // @implements s-qvff9k@3d4e66c
13
+ // @implements s-w9knti@d14fa7f
14
+ // @implements s-9oyy6g@e000a9d
15
+ // @implements s-f9mmtf@b19805c
16
+ // @implements s-0fiha8@6f2ad42
17
+ // @implements s-0399cx@d927ab4
18
+ // @implements s-dvurdo@4167f20
19
+ // @implements s-ddc95k@7196ab1
20
+ // @implements s-2bfbql@55fab0b
21
+ // @implements s-0ys3dr@2da1f17
22
+ // @implements s-fm0j9h@debe84e
23
+ // @implements s-51ncq9@71d9dc3
24
+ // @implements s-cd2jke@3b2070f
25
+ // @implements s-hcsr5l@76b1b4a
26
+ // @implements s-9exkpo@4f91381
27
+ // @implements s-sse100@ccc5706
28
+ // @implements s-qzz6xu@29b0633
29
+ // @implements s-9e2f7d@5833072
30
+ // @implements s-mvtsgu@0347ca1
31
+ // @implements s-r2o7ne@b4bd635
32
+ // @implements s-30veoz@0e078f1
33
+ // @implements s-hbdizs@70b7167
34
+ // @implements s-zopa6h@ac3585f
35
+ // @implements s-yx8tuh@612f0b5
36
+ // @implements s-xrkyna@a015af7
37
+ // @implements s-lzdklu@60f00ba
38
+ // @implements s-vhcbdr@9334045
39
+ // @implements s-cals8z@4a2d967
40
+ // @implements s-rueqkl@c59d2d4
41
+ // @implements s-hnxl58@6be6d28
42
+ // @implements s-zmh8z6@a41e05d
43
+ // @implements s-51kf4i@4cf5a6a
44
+ // @implements s-2s3q95@7154581
45
+ // @implements s-akhsa6@5f649c1
46
+ // @implements s-pos9d5@0cf7fea
47
+ // @implements s-o5jdcy@e401a4f
48
+ // @implements s-19wno0@e8ab1b2
49
+ // @implements s-09iime@6063232
50
+ // @implements s-p66ewo@a59e771
51
+ // @implements s-4y820y@d019d03
52
+ // @implements s-vfgnzf@47216b8
53
+ // @implements s-vwase3@5417720
54
+ // @implements s-4w8iuj@b42f67f
55
+ // @implements s-83u8i8@a535264
56
+ // @implements s-1mid3a@fe7e2f2
57
+ // @implements s-6uvlo6@47f8459
58
+ // @implements s-t8xsan@8e2e532
59
+ // @implements s-jpjbwh@b74a3f0
60
+ // @implements s-yufw52@ad4e715
61
+ // @implements s-14wrxl@7a4474b
62
+ // @implements s-amd1ad@5c9c949
63
+ // @implements s-8hvj7z@4ee0664
64
+ // @implements s-wapj39@5412047
65
+ // @implements s-twjecx@96950c4
66
+ // @implements s-g5gxep@784430f
67
+ // @implements s-axz9ts@7132791
68
+ const fs = require( "fs" )
69
+ const path = require( "path" )
70
+ const { URL } = require( "url" )
71
+ const { spawnSync } = require( "child_process" )
72
+ const {
73
+ fvsProblem, fvsAddProblem, fbIsoTime, sFilePage, sFileQuestion, sFileAnswer,
74
+ sFilePending, fsQuestionId, fsLegacyQuestionId, fsBranchHash, faQuestionOptions,
75
+ sFileStatus, fvsStatusProblem,
76
+ sFileHistory, sFileConflict, faQuestionOfId, sFileThoughts,
77
+ } = require( "./check" )
78
+ const { fxWithLock } = require( "./project-lock" )
79
+ const { fsHtml } = require( "./page-build" )
80
+ const { fvaQuestionFromPage } = require( "./import-page" )
81
+
82
+ const sFileBuilt = "tmp/idd/page.html"
83
+ const sUsage = [
84
+ "usage: idd <subcommand> [arguments]",
85
+ " idd guide",
86
+ " idd viewer-url <owner/repository> <code-branch>",
87
+ " idd init <title> <branch-hash>",
88
+ " idd branch-hash <repository-id> <full-ref>",
89
+ " idd check",
90
+ " idd build",
91
+ " idd add (a JSON question on stdin)",
92
+ " idd archive <question-id> <replacement-id>",
93
+ " idd import <page>",
94
+ " idd answer <id> (a JSON answer on stdin)",
95
+ " idd status <session-id> <blocked|working|clear>",
96
+ " idd count [--nosync] [--type <c|u|a|s|conflicts|unanswered|answered|superseded>]",
97
+ " idd thoughts [--nosync]",
98
+ " idd clear-pending <id> <time>",
99
+ " idd wait <question-id> [interval-seconds]",
100
+ ].join( "\n" )
101
+
102
+ class Project {
103
+ constructor( sRoot ){
104
+ this.sRoot = sRoot
105
+ }
106
+
107
+ sPath( sFile ){
108
+ return path.join( this.sRoot, sFile )
109
+ }
110
+
111
+ bExists( sFile ){
112
+ return fs.existsSync( this.sPath( sFile ))
113
+ }
114
+
115
+ sRead( sFile ){
116
+ return fs.readFileSync( this.sPath( sFile ), "utf8" )
117
+ }
118
+
119
+ sReadOrNull( sFile ){
120
+ return this.bExists( sFile ) ? this.sRead( sFile ) : null
121
+ }
122
+
123
+ write( sFile, sText ){
124
+ fs.mkdirSync( path.dirname( this.sPath( sFile )), { recursive: true })
125
+ const sDirTemp = this.sPath( "tmp/idd" )
126
+ fs.mkdirSync( sDirTemp, { recursive: true })
127
+ const sTemp = path.join(
128
+ sDirTemp,
129
+ path.basename( sFile ) + "." + process.pid + "." +
130
+ Math.random().toString( 36 ).slice( 2 ) + ".tmp"
131
+ )
132
+ fs.writeFileSync( sTemp, sText )
133
+ fs.renameSync( sTemp, this.sPath( sFile ))
134
+ }
135
+
136
+ writeJson( sFile, x ){
137
+ this.write( sFile, JSON.stringify( x, null, 2 ) + "\n" )
138
+ }
139
+ }
140
+
141
+ function aResult( nExit, sOut = "", sErr = "" ){
142
+ return { nExit, sOut, sErr }
143
+ }
144
+
145
+ function aUsage(){
146
+ return aResult( 2, "", sUsage + "\n" )
147
+ }
148
+
149
+ function aGuide(){
150
+ return aResult( 0, fs.readFileSync(
151
+ path.join( __dirname, "..", "INSTRUCTIONS.md" ), "utf8"
152
+ ))
153
+ }
154
+
155
+ function aViewerUrl( project, vsArg ){
156
+ const [ sRepository, sBranch ] = vsArg
157
+ if( !/^[^/\s]+\/[^/\s]+$/.test( sRepository ) ){
158
+ return aResult( 2, "", "repository must be <owner>/<repository>\n" )
159
+ }
160
+ if( !sBranch || /[\r\n]/.test( sBranch ) ){
161
+ return aResult( 2, "", "code-branch must be non-empty\n" )
162
+ }
163
+ let uViewer
164
+ try {
165
+ uViewer = new URL(
166
+ process.env.IDD_VIEWER_URL ||
167
+ "https://idd-service-778162731919.us-east4.run.app"
168
+ )
169
+ } catch( xError ){
170
+ return aResult( 2, "", "IDD_VIEWER_URL is not a valid URL\n" )
171
+ }
172
+ if( ![ "http:", "https:" ].includes( uViewer.protocol ) ){
173
+ return aResult( 2, "", "IDD_VIEWER_URL must use HTTP or HTTPS\n" )
174
+ }
175
+ uViewer.searchParams.set( "repo", sRepository )
176
+ uViewer.searchParams.set( "branch", sBranch )
177
+ return aResult( 0, uViewer.toString() + "\n" )
178
+ }
179
+
180
+ function fzGit( sRoot, vsArgs ){
181
+ const aRun = spawnSync( "git", vsArgs, {
182
+ cwd: sRoot, encoding: "utf8", timeout: 120000,
183
+ })
184
+ if( aRun.error || aRun.status !== 0 ){
185
+ return {
186
+ error: ( aRun.error && aRun.error.message ) ||
187
+ ( aRun.stderr || "git " + vsArgs[ 0 ] + " failed" ).trim(),
188
+ }
189
+ }
190
+ return { out: aRun.stdout || "" }
191
+ }
192
+
193
+ async function fOpenEventStream(){
194
+ let uEvents
195
+ try {
196
+ const uViewer = new URL(
197
+ process.env.IDD_VIEWER_URL ||
198
+ "https://idd-service-778162731919.us-east4.run.app"
199
+ )
200
+ if( ![ "http:", "https:" ].includes( uViewer.protocol )) return null
201
+ uEvents = new URL( "/events", uViewer )
202
+ } catch( xError ){
203
+ return null
204
+ }
205
+
206
+ const aStream = (async function*(){
207
+ const aController = new AbortController()
208
+ const nConnectTimeout = setTimeout( () => aController.abort(), 10000 )
209
+ let aResponse
210
+ try {
211
+ aResponse = await fetch( uEvents, {
212
+ headers: { Accept: "text/event-stream" }, signal: aController.signal,
213
+ })
214
+ } catch( xError ){
215
+ return
216
+ } finally {
217
+ clearTimeout( nConnectTimeout )
218
+ }
219
+ if( !aResponse.ok || !aResponse.body ) return
220
+
221
+ const aReader = aResponse.body.getReader()
222
+ const aDecoder = new TextDecoder()
223
+ let sBuffer = ""
224
+ try {
225
+ for(;;){
226
+ const aRead = await aReader.read()
227
+ if( aRead.done ) return
228
+ sBuffer += aDecoder.decode( aRead.value, { stream: true })
229
+ const vaFrame = sBuffer.split( /\r?\n\r?\n/ )
230
+ sBuffer = vaFrame.pop()
231
+ for( const sFrame of vaFrame ){
232
+ let sEvent = "message"
233
+ const vsData = []
234
+ for( const sLine of sFrame.split( /\r?\n/ )){
235
+ if( sLine.startsWith( ":" )) continue
236
+ const nColon = sLine.indexOf( ":" )
237
+ const sField = nColon < 0 ? sLine : sLine.slice( 0, nColon )
238
+ const sValue = nColon < 0 ? "" : sLine.slice( nColon + 1 ).replace( /^ /, "" )
239
+ if( sField === "event" ) sEvent = sValue
240
+ else if( sField === "data" ) vsData.push( sValue )
241
+ }
242
+ if( vsData.length ) yield { event: sEvent, data: vsData.join( "\n" ) }
243
+ }
244
+ }
245
+ } finally {
246
+ await aReader.cancel().catch( () => {} )
247
+ }
248
+ })()
249
+
250
+ try {
251
+ const aReady = await aStream.next()
252
+ if( aReady.done || aReady.value.event !== "ready" ){
253
+ await aStream.return()
254
+ return null
255
+ }
256
+ const aReadyData = JSON.parse( aReady.value.data )
257
+ if( aReadyData.push !== true ){
258
+ await aStream.return()
259
+ return { push: false }
260
+ }
261
+ return { push: true, stream: aStream }
262
+ } catch( xError ){
263
+ await aStream.return().catch( () => {} )
264
+ return null
265
+ }
266
+ }
267
+
268
+ function fzWait( sRoot, vsArg ){
269
+ const [ sId, sInterval = "30" ] = vsArg
270
+ if( !/^[0-9a-f]{64}$/.test( sId ) ){
271
+ return Promise.resolve( aResult( 2, "", "question-id must be a lowercase SHA-256 digest\n" ))
272
+ }
273
+ if( !/^[0-9]+$/.test( sInterval ) ||
274
+ Number( sInterval ) < 1 || Number( sInterval ) > 3600 ){
275
+ return Promise.resolve( aResult( 2, "", "interval-seconds must be from 1 to 3600\n" ))
276
+ }
277
+ const aRoot = fzGit( sRoot, [ "rev-parse", "--show-toplevel" ] )
278
+ const aBranch = fzGit( sRoot, [ "branch", "--show-current" ] )
279
+ if( aRoot.error || aBranch.error || !aBranch.out.trim().startsWith( "idd-" ) ){
280
+ return Promise.resolve( aResult( 1, "", "wait must run from a checked-out IDD data worktree\n" ))
281
+ }
282
+ const sDataRoot = aRoot.out.trim()
283
+ const sBranch = aBranch.out.trim()
284
+ const aStatus = fzGit( sDataRoot, [ "status", "--porcelain" ] )
285
+ if( aStatus.error ) return Promise.resolve( aResult( 1, "", aStatus.error + "\n" ))
286
+ if( aStatus.out ){
287
+ return Promise.resolve( aResult( 1, "", "wait requires a clean IDD data worktree\n" ))
288
+ }
289
+
290
+ async function faPoll(){
291
+ let aEvents = await fOpenEventStream()
292
+ for(;;){
293
+ for( const vsArgs of [
294
+ [ "fetch", "--quiet", "origin", "refs/heads/" + sBranch ],
295
+ [ "merge", "--ff-only", "FETCH_HEAD" ],
296
+ ]){
297
+ const aGit = fzGit( sDataRoot, vsArgs )
298
+ if( aGit.error ){
299
+ return aResult( 1, "", "idd wait: " + aGit.error + "\n" )
300
+ }
301
+ }
302
+ let bHasAnswer = false
303
+ let aAnswer = null
304
+ try {
305
+ const aQuestions = JSON.parse( fs.readFileSync(
306
+ path.join( sDataRoot, sFileQuestion ), "utf8"
307
+ ))
308
+ const aQuestion = Array.isArray( aQuestions )
309
+ ? aQuestions.find(( a ) => a && a.id === sId ) : null
310
+ if( !aQuestion ){
311
+ return aResult( 2, "", "question-id is not active on the IDD branch\n" )
312
+ }
313
+ const sAnswerFile = path.join( sDataRoot, sFileAnswer )
314
+ const aAnswers = fs.existsSync( sAnswerFile )
315
+ ? JSON.parse( fs.readFileSync( sAnswerFile, "utf8" )) : {}
316
+ if( !fbObject( aAnswers )){
317
+ return aResult( 1, "", sFileAnswer + " is not a JSON object\n" )
318
+ }
319
+ bHasAnswer = Object.prototype.hasOwnProperty.call( aAnswers, sId )
320
+ aAnswer = bHasAnswer ? aAnswers[ sId ] : null
321
+ if( bHasAnswer && ( !fbObject( aAnswer ) || !fbIsoTime( aAnswer.time ) ||
322
+ typeof aAnswer.choice !== "string" || typeof aAnswer.note !== "string" )){
323
+ return aResult( 1, "", sFileAnswer + ": malformed accepted answer for " + sId + "\n" )
324
+ }
325
+ if( bHasAnswer ){
326
+ const project = new Project( sDataRoot )
327
+ const aRecorded = fxWithLock(
328
+ sDataRoot,
329
+ () => {
330
+ const aPending = aParsedOrNull(
331
+ project.sReadOrNull( sFilePending ) || "{}"
332
+ )
333
+ if( !fbObject( aPending )){
334
+ return aResult( 1, "", sFilePending + " is not a JSON object\n" )
335
+ }
336
+ project.writeJson( sFilePending, {
337
+ ...aPending,
338
+ [ sId ]: {
339
+ choice: aAnswer.choice, note: aAnswer.note, time: aAnswer.time,
340
+ },
341
+ })
342
+ return aResult( 0 )
343
+ },
344
+ () => aResult( 2, "", "the project's files are held by another run\n" )
345
+ )
346
+ if( aRecorded.nExit !== 0 ) return aRecorded
347
+ }
348
+ } catch( xError ){
349
+ return aResult( 1, "", "idd wait: " + xError.message + "\n" )
350
+ }
351
+ if( bHasAnswer ) return aResult( 0, JSON.stringify( aAnswer, null, 2 ) + "\n" )
352
+
353
+ if( aEvents && aEvents.push ){
354
+ let bInvalidated = false
355
+ try {
356
+ for(;;){
357
+ const aEvent = await aEvents.stream.next()
358
+ if( aEvent.done ) break
359
+ if( aEvent.value.event === "idd-update" ){
360
+ bInvalidated = true
361
+ break
362
+ }
363
+ }
364
+ } catch( xError ){}
365
+ if( bInvalidated ) continue
366
+ await aEvents.stream.return().catch( () => {} )
367
+ aEvents = null
368
+ } else {
369
+ await new Promise(( fResolve ) => setTimeout( fResolve, Number( sInterval ) * 1000 ))
370
+ aEvents = await fOpenEventStream()
371
+ }
372
+ }
373
+ }
374
+ return faPoll()
375
+ }
376
+
377
+ function aBranchHash( project, vsArg ){
378
+ const [ sRepositoryId, sFullRef ] = vsArg
379
+ if( !/^[1-9][0-9]*$/.test( sRepositoryId ) ){
380
+ return aResult( 2, "", "repository-id is not a positive decimal integer\n" )
381
+ }
382
+ if( !sFullRef.startsWith( "refs/heads/" ) || !sFullRef.slice( 11 ) ){
383
+ return aResult( 2, "", "full-ref is not a non-empty refs/heads branch\n" )
384
+ }
385
+ return aResult( 0, fsBranchHash( sRepositoryId, sFullRef ) + "\n" )
386
+ }
387
+
388
+ function aInit( project, vsArg ){
389
+ const sExisting = [ sFilePage, sFileQuestion ].find(
390
+ ( s ) => project.bExists( s )
391
+ )
392
+ if( sExisting ){
393
+ return aResult( 2, "", sExisting + " already exists\n" )
394
+ }
395
+ if( !/^[0-9a-f]{64}$/.test( vsArg[ 1 ] )){
396
+ return aResult( 2, "", "branch-hash is not a lowercase SHA-256 digest\n" )
397
+ }
398
+ project.writeJson( sFilePage, {
399
+ title: vsArg[ 0 ], url: null, branchHash: vsArg[ 1 ],
400
+ })
401
+ project.writeJson( sFileQuestion, [] )
402
+ return aResult( 0 )
403
+ }
404
+
405
+ function fvsProblemOf( project ){
406
+ return fvsProblem(
407
+ project.sRead( sFilePage ), project.sReadOrNull( sFileQuestion ),
408
+ project.sReadOrNull( sFileAnswer ), project.sReadOrNull( sFilePending ),
409
+ project.sReadOrNull( sFileStatus ), project.sReadOrNull( sFileHistory ),
410
+ project.sReadOrNull( sFileConflict ),
411
+ project.sReadOrNull( sFileThoughts )
412
+ )
413
+ }
414
+
415
+ function fbObject( x ){
416
+ return typeof x == "object" && x !== null && !Array.isArray( x )
417
+ }
418
+
419
+ function aProblem( vsProblem ){
420
+ return aResult( 1, vsProblem.join( "\n" ) + "\n" )
421
+ }
422
+
423
+ function aCheck( project ){
424
+ const vsProblem = fvsProblemOf( project )
425
+ return vsProblem.length ? aProblem( vsProblem ) : aResult( 0, "idd: ok\n" )
426
+ }
427
+
428
+ function aBuild( project ){
429
+ const vsProblem = fvsProblemOf( project )
430
+ if( vsProblem.length ){
431
+ return aProblem( vsProblem )
432
+ }
433
+ project.write(
434
+ sFileBuilt,
435
+ fsHtml(
436
+ JSON.parse( project.sRead( sFilePage )),
437
+ JSON.parse( project.sRead( sFileQuestion ))
438
+ )
439
+ )
440
+ return aResult( 0, sFileBuilt + "\n" )
441
+ }
442
+
443
+ function aAdd( project, vsArg, fsStdin ){
444
+ const aIn = aParsedOrNull( fsStdin())
445
+ const vsInput = fvsAddProblem( aIn )
446
+ if( vsInput.length ){
447
+ return aResult( 2, "", vsInput.join( "\n" ) + "\n" )
448
+ }
449
+ const vsProblem = fvsProblemOf( project )
450
+ if( vsProblem.length ){
451
+ return aProblem( vsProblem )
452
+ }
453
+ const vaQuestion = JSON.parse( project.sRead( sFileQuestion ))
454
+ const aPage = JSON.parse( project.sRead( sFilePage ))
455
+ const aNew = {
456
+ ...aIn, options: faQuestionOptions( aIn.options ),
457
+ branchHash: aPage.branchHash,
458
+ }
459
+ const sId = fsQuestionId( aPage.branchHash, aNew )
460
+ const sLegacyId = fsLegacyQuestionId( aPage.branchHash, aNew )
461
+ const aExisting = vaQuestion.find(( a ) => {
462
+ const aWithDisplayOption = { ...a, options: faQuestionOptions( a.options ) }
463
+ return fsQuestionId( a.branchHash, aWithDisplayOption ) === sId ||
464
+ fsLegacyQuestionId( a.branchHash, aWithDisplayOption ) === sLegacyId
465
+ })
466
+ if( aExisting ){
467
+ return aResult( 2, "", "question already exists:\n" +
468
+ JSON.stringify( aExisting, null, 2 ) + "\n" )
469
+ }
470
+ project.writeJson(
471
+ sFileQuestion,
472
+ [ ...vaQuestion, {
473
+ id: sId, branchHash: aPage.branchHash,
474
+ title: aNew.title, body: aNew.body, options: aNew.options,
475
+ createdAt: new Date().toISOString(),
476
+ } ]
477
+ )
478
+ return aResult( 0, sId + "\n" )
479
+ }
480
+
481
+ function aArchive( project, vsArg ){
482
+ const [ sId, sReplacementId ] = vsArg
483
+ if( !/^[0-9a-f]{64}$/.test( sId ) || !/^[0-9a-f]{64}$/.test( sReplacementId )){
484
+ return aResult( 2, "", "question ids must be lowercase SHA-256 digests\n" )
485
+ }
486
+ if( sId === sReplacementId ){
487
+ return aResult( 2, "", "question cannot replace itself\n" )
488
+ }
489
+ const vsProblem = fvsProblemOf( project )
490
+ if( vsProblem.length ) return aProblem( vsProblem )
491
+ const vaQuestion = JSON.parse( project.sRead( sFileQuestion ))
492
+ const nQuestion = vaQuestion.findIndex(( a ) => a.id === sId )
493
+ if( nQuestion < 0 ){
494
+ return aResult( 2, "", "question-id is not active\n" )
495
+ }
496
+ if( !vaQuestion.some(( a ) => a.id === sReplacementId )){
497
+ return aResult( 2, "", "replacement-id is not active\n" )
498
+ }
499
+ const vaHistory = project.sReadOrNull( sFileHistory ) === null
500
+ ? [] : JSON.parse( project.sRead( sFileHistory ))
501
+ const aQuestion = vaQuestion[ nQuestion ]
502
+ project.writeJson( sFileHistory, [ ...vaHistory, {
503
+ questionId: sId,
504
+ question: aQuestion,
505
+ replacedBy: sReplacementId,
506
+ time: new Date().toISOString(),
507
+ } ])
508
+ project.writeJson( sFileQuestion,
509
+ vaQuestion.filter(( a ) => a.id !== sId ))
510
+ return aResult( 0 )
511
+ }
512
+
513
+ function fbHoldsQuestion( project ){
514
+ try {
515
+ const va = JSON.parse( project.sReadOrNull( sFileQuestion ))
516
+ return Array.isArray( va ) && va.length > 0
517
+ } catch( xError ){
518
+ return false
519
+ }
520
+ }
521
+
522
+ function aParsedOrNull( sText ){
523
+ try {
524
+ return JSON.parse( sText )
525
+ } catch( xError ){
526
+ return null
527
+ }
528
+ }
529
+
530
+ function fsAnswerProblem( aIn, aQuestion ){
531
+ if( !aQuestion ){
532
+ return "the id is the id of no question of " + sFileQuestion
533
+ }
534
+ if( !fbObject( aIn )){
535
+ return "stdin does not hold a JSON object"
536
+ }
537
+ const vaOption = faQuestionOptions( aQuestion.options )
538
+ if( typeof aIn.sChoice != "string" || ( aIn.sChoice !== "" &&
539
+ !vaOption.some(( a ) => a && a.key === aIn.sChoice ))){
540
+ return "sChoice is neither \"\" nor a key of the question's options"
541
+ }
542
+ if( typeof aIn.sNote != "string" ){
543
+ return "sNote is not a string"
544
+ }
545
+ if( !fbIsoTime( aIn.dtm )){
546
+ return "dtm is not an ISO 8601 time"
547
+ }
548
+ return null
549
+ }
550
+
551
+ function aAnswer( project, vsArg, fsStdin ){
552
+ const sId = vsArg[ 0 ]
553
+ const vaQuestion = aParsedOrNull( project.sReadOrNull( sFileQuestion ))
554
+ const aQuestion = Array.isArray( vaQuestion )
555
+ ? vaQuestion.find(( a ) => fbObject( a ) && a.id === sId ) : null
556
+ const aIn = aParsedOrNull( fsStdin())
557
+ const sProblem = fsAnswerProblem( aIn, aQuestion )
558
+ if( sProblem ){
559
+ return aResult( 2, "", sProblem + "\n" )
560
+ }
561
+ const sTextAnswer = project.sReadOrNull( sFileAnswer )
562
+ const aAnswerFile = sTextAnswer === null ? {} : aParsedOrNull( sTextAnswer )
563
+ if( !fbObject( aAnswerFile )){
564
+ return aResult( 2, "", sFileAnswer + " is not a JSON object\n" )
565
+ }
566
+ const aOld = Object.prototype.hasOwnProperty.call( aAnswerFile, sId )
567
+ ? aAnswerFile[ sId ] : null
568
+ if( Object.prototype.hasOwnProperty.call( aAnswerFile, sId )){
569
+ return aResult( 2, "", "answer already accepted for " + sId + ":\n" +
570
+ JSON.stringify( aOld, null, 2 ) + "\n" )
571
+ }
572
+ const sTextPending = project.sReadOrNull( sFilePending )
573
+ const aPending = sTextPending === null ? {} : aParsedOrNull( sTextPending )
574
+ if( !fbObject( aPending )){
575
+ return aResult( 2, "", sFilePending + " is not a JSON object\n" )
576
+ }
577
+ const aEntry = {
578
+ choice: aIn.sChoice, note: aIn.sNote, time: aIn.dtm,
579
+ }
580
+ project.writeJson( sFileAnswer, { ...aAnswerFile, [ sId ]: aEntry })
581
+ project.writeJson(
582
+ sFilePending,
583
+ { ...aPending, [ sId ]: aEntry }
584
+ )
585
+ return aResult( 0 )
586
+ }
587
+
588
+ function aClearPending( project, vsArg ){
589
+ const [ sId, sTime ] = vsArg
590
+ const aQuestionOfId = faQuestionOfId(
591
+ project.sReadOrNull( sFileQuestion ), project.sReadOrNull( sFileHistory )
592
+ )
593
+ const bHasQuestion = Object.prototype.hasOwnProperty.call( aQuestionOfId, sId )
594
+ if( !bHasQuestion ){
595
+ return aResult( 2, "", "the id is the id of no question of "
596
+ + sFileQuestion + "\n" )
597
+ }
598
+ if( !fbIsoTime( sTime )){
599
+ return aResult( 2, "", "time is not an ISO 8601 time\n" )
600
+ }
601
+ const sTextPending = project.sReadOrNull( sFilePending )
602
+ if( sTextPending === null ){
603
+ return aResult( 0 )
604
+ }
605
+ const aPending = aParsedOrNull( sTextPending )
606
+ if( !fbObject( aPending )){
607
+ return aResult( 1, sFilePending + ": not a JSON object\n" )
608
+ }
609
+ const aCurrent = aPending[ sId ]
610
+ if( !fbObject( aCurrent ) || aCurrent.time !== sTime ){
611
+ return aResult( 0 )
612
+ }
613
+ const aNext = { ...aPending }
614
+ delete aNext[ sId ]
615
+ project.writeJson( sFilePending, aNext )
616
+ return aResult( 0 )
617
+ }
618
+
619
+ function aStatus( project, vsArg ){
620
+ const [ sSessionId, sState ] = vsArg
621
+ if( !/^[A-Za-z0-9._:-]{1,128}$/.test( sSessionId )){
622
+ return aResult( 2, "", "session-id must be 1 to 128 ASCII letters, digits, periods, underscores, colons or hyphens\n" )
623
+ }
624
+ if( ![ "blocked", "working", "clear" ].includes( sState )){
625
+ return aResult( 2, "", "state must be blocked, working or clear\n" )
626
+ }
627
+ const sTextStatus = project.sReadOrNull( sFileStatus )
628
+ const aStatus = sTextStatus === null ? {} : aParsedOrNull( sTextStatus )
629
+ const vsProblem = fvsStatusProblem( aStatus )
630
+ if( vsProblem.length ){
631
+ return aResult( 1, "", sFileStatus + ": " + vsProblem.join( "; " ) + "\n" )
632
+ }
633
+ if( sState === "clear" ){
634
+ if( !Object.prototype.hasOwnProperty.call( aStatus, sSessionId )){
635
+ return aResult( 0 )
636
+ }
637
+ delete aStatus[ sSessionId ]
638
+ } else {
639
+ aStatus[ sSessionId ] = {
640
+ blocked: sState === "blocked",
641
+ updatedAt: new Date().toISOString(),
642
+ }
643
+ }
644
+ project.writeJson( sFileStatus, aStatus )
645
+ return aResult( 0 )
646
+ }
647
+
648
+ function aCount( project, vsArg ){
649
+ let bNoSync = false
650
+ let sType = null
651
+ for( let n = 0; n < vsArg.length; n++ ){
652
+ if( vsArg[ n ] === "--nosync" && !bNoSync ){
653
+ bNoSync = true
654
+ } else if( vsArg[ n ] === "--type" && sType === null && n + 1 < vsArg.length ){
655
+ sType = vsArg[ ++n ]
656
+ } else {
657
+ return aUsage()
658
+ }
659
+ }
660
+ const mType = {
661
+ c: "conflicts", conflicts: "conflicts",
662
+ u: "unanswered", unanswered: "unanswered",
663
+ a: "answered", answered: "answered",
664
+ s: "superseded", superseded: "superseded",
665
+ }
666
+ if( sType !== null && !Object.prototype.hasOwnProperty.call( mType, sType )){
667
+ return aUsage()
668
+ }
669
+
670
+ if( !bNoSync ){
671
+ const aRoot = fzGit( project.sRoot, [ "rev-parse", "--show-toplevel" ] )
672
+ const aBranch = fzGit( project.sRoot, [ "branch", "--show-current" ] )
673
+ if( aRoot.error || aBranch.error || !/^idd-.+/.test( aBranch.out.trim() )){
674
+ return aResult( 1, "", "idd count must run from a checked-out IDD data worktree\n" )
675
+ }
676
+ const sDataRoot = aRoot.out.trim()
677
+ const aStatus = fzGit( sDataRoot, [ "status", "--porcelain" ] )
678
+ if( aStatus.error ) return aResult( 1, "", aStatus.error + "\n" )
679
+ if( aStatus.out ){
680
+ return aResult( 1, "", "idd count requires a clean IDD data worktree before sync\n" )
681
+ }
682
+ for( const vsArgs of [
683
+ [ "fetch", "--quiet", "origin", "refs/heads/" + aBranch.out.trim() ],
684
+ [ "merge", "--ff-only", "FETCH_HEAD" ],
685
+ ]){
686
+ const aGit = fzGit( sDataRoot, vsArgs )
687
+ if( aGit.error ) return aResult( 1, "", "idd count: " + aGit.error + "\n" )
688
+ }
689
+ }
690
+
691
+ const vsProblem = fvsProblemOf( project )
692
+ if( vsProblem.length ){
693
+ return aResult( 1, "", vsProblem.join( "\n" ) + "\n" )
694
+ }
695
+ let vaQuestion, aAnswers, vaConflict, vaHistory
696
+ try {
697
+ vaQuestion = JSON.parse( project.sRead( sFileQuestion ))
698
+ aAnswers = project.sReadOrNull( sFileAnswer ) === null
699
+ ? {} : JSON.parse( project.sRead( sFileAnswer ))
700
+ vaConflict = project.sReadOrNull( sFileConflict ) === null
701
+ ? [] : JSON.parse( project.sRead( sFileConflict ))
702
+ vaHistory = project.sReadOrNull( sFileHistory ) === null
703
+ ? [] : JSON.parse( project.sRead( sFileHistory ))
704
+ } catch( xError ){
705
+ return aResult( 1, "", "idd count: " + xError.message + "\n" )
706
+ }
707
+ const aCounts = { conflicts: 0, unanswered: 0, answered: 0, superseded: vaHistory.length }
708
+ for( const aQuestion of vaQuestion ){
709
+ const aAccepted = aAnswers[ aQuestion.id ]
710
+ const vaRejected = vaConflict.filter(( a ) => a.questionId === aQuestion.id )
711
+ const bConflict = vaRejected.some(( a ) => !aAccepted ||
712
+ a.choice !== aAccepted.choice || a.note !== aAccepted.note )
713
+ const bAnswered = !!aAccepted && !!( aAccepted.choice || aAccepted.note.trim())
714
+ if( bConflict ) aCounts.conflicts++
715
+ else if( bAnswered ) aCounts.answered++
716
+ else aCounts.unanswered++
717
+ }
718
+ if( sType !== null ) return aResult( 0, String( aCounts[ mType[ sType ]] ) + "\n" )
719
+ return aResult( 0, Object.entries( aCounts ).map(
720
+ ([ sName, nCount ]) => sName + ": " + nCount
721
+ ).join( "\n" ) + "\n" )
722
+ }
723
+
724
+ function aThoughts( project, vsArg ){
725
+ const bNoSync = vsArg.length === 1 && vsArg[ 0 ] === "--nosync"
726
+ if( vsArg.length > 1 || ( vsArg.length === 1 && !bNoSync )){
727
+ return aUsage()
728
+ }
729
+ if( !bNoSync ){
730
+ const aRoot = fzGit( project.sRoot, [ "rev-parse", "--show-toplevel" ] )
731
+ const aBranch = fzGit( project.sRoot, [ "branch", "--show-current" ] )
732
+ if( aRoot.error || aBranch.error ||
733
+ !/^idd-.+/.test( aBranch.out.trim() )){
734
+ return aResult(
735
+ 1, "", "idd thoughts must run from a checked-out IDD data worktree\n"
736
+ )
737
+ }
738
+ const sDataRoot = aRoot.out.trim()
739
+ const sBranch = aBranch.out.trim()
740
+ const aStatus = fzGit( sDataRoot, [ "status", "--porcelain" ] )
741
+ if( aStatus.error ) return aResult( 1, "", aStatus.error + "\n" )
742
+ if( aStatus.out ){
743
+ return aResult(
744
+ 1, "", "idd thoughts requires a clean IDD data worktree before sync\n"
745
+ )
746
+ }
747
+ for( const vsArgs of [
748
+ [ "fetch", "--quiet", "origin", "refs/heads/" + sBranch ],
749
+ [ "merge", "--ff-only", "FETCH_HEAD" ],
750
+ ] ){
751
+ const aGit = fzGit( sDataRoot, vsArgs )
752
+ if( aGit.error ){
753
+ return aResult( 1, "", "idd thoughts: " + aGit.error + "\n" )
754
+ }
755
+ }
756
+ }
757
+
758
+ const vsProblem = fvsProblemOf( project )
759
+ if( vsProblem.length ) return aProblem( vsProblem )
760
+ const sTextThoughts = project.sReadOrNull( sFileThoughts )
761
+ const vaThought = sTextThoughts === null ? [] : JSON.parse( sTextThoughts )
762
+ if( !vaThought.length ) return aResult( 0, "idd: no thoughts\n" )
763
+ const sOut = vaThought.map(( aThought ) =>
764
+ aThought.id + " · " + aThought.createdAt + "\n" + aThought.text
765
+ ).join( "\n\n" )
766
+ return aResult( 0, sOut + "\n" )
767
+ }
768
+
769
+ function aImport( project, vsArg ){
770
+ const sPage = vsArg[ 0 ]
771
+ if( fbHoldsQuestion( project )){
772
+ return aResult( 2, "", sFileQuestion + " already holds questions\n" )
773
+ }
774
+ let vaQuestion
775
+ try {
776
+ const aPage = JSON.parse( project.sRead( sFilePage ))
777
+ vaQuestion = fvaQuestionFromPage(
778
+ fs.readFileSync( path.resolve( project.sRoot, sPage ), "utf8" )
779
+ ).map(( a ) => ({
780
+ ...a,
781
+ branchHash: aPage.branchHash,
782
+ id: fsQuestionId( aPage.branchHash, a ),
783
+ createdAt: new Date().toISOString(),
784
+ }))
785
+ } catch( xError ){
786
+ return aResult( 2, "", sPage + ": " + xError.message + "\n" )
787
+ }
788
+ project.writeJson( sFileQuestion, vaQuestion )
789
+ return aResult( 0 )
790
+ }
791
+
792
+ const aSpec = {
793
+ "guide": { cArg: 0, fa: aGuide, bNeedsPage: false, bWrites: false },
794
+ "viewer-url": { cArg: 2, fa: aViewerUrl, bNeedsPage: false, bWrites: false },
795
+ "init": { cArg: 2, fa: aInit, bNeedsPage: false, bWrites: true },
796
+ "branch-hash": { cArg: 2, fa: aBranchHash, bNeedsPage: false, bWrites: false },
797
+ "check": { cArg: 0, fa: aCheck, bNeedsPage: true, bWrites: false, bLocks: true },
798
+ "build": { cArg: 0, fa: aBuild, bNeedsPage: true, bWrites: false, bLocks: true },
799
+ "add": { cArg: 0, fa: aAdd, bNeedsPage: true, bWrites: true },
800
+ "archive": { cArg: 2, fa: aArchive, bNeedsPage: true, bWrites: true },
801
+ "import": { cArg: 1, fa: aImport, bNeedsPage: false, bWrites: true },
802
+ "answer": { cArg: 1, fa: aAnswer, bNeedsPage: true, bWrites: true },
803
+ "clear-pending": { cArg: 2, fa: aClearPending, bNeedsPage: true, bWrites: true },
804
+ "status": { cArg: 2, fa: aStatus, bNeedsPage: true, bWrites: true },
805
+ }
806
+
807
+ function aRun( sRoot, vsArg, fsStdin = () => "" ){
808
+ const [ sCommand, ...vsRest ] = vsArg
809
+ if( sCommand === "wait" && vsRest.length >= 1 && vsRest.length <= 2 ){
810
+ return fzWait( sRoot, vsRest )
811
+ }
812
+ if( sCommand === "count" ){
813
+ const project = new Project( sRoot )
814
+ if( !project.bExists( sFilePage )){
815
+ return aResult( 2, "", sFilePage + " does not exist\n" )
816
+ }
817
+ return aCount( project, vsRest )
818
+ }
819
+ if( sCommand === "thoughts" ){
820
+ const project = new Project( sRoot )
821
+ if( !project.bExists( sFilePage )){
822
+ return aResult( 2, "", sFilePage + " does not exist\n" )
823
+ }
824
+ return aThoughts( project, vsRest )
825
+ }
826
+ const aCommand = Object.prototype.hasOwnProperty.call( aSpec, sCommand )
827
+ ? aSpec[ sCommand ] : null
828
+ if( !aCommand || vsRest.length != aCommand.cArg ){
829
+ return aUsage()
830
+ }
831
+ const project = new Project( sRoot )
832
+ if( aCommand.bNeedsPage && !project.bExists( sFilePage )){
833
+ return aResult( 2, "", sFilePage + " does not exist\n" )
834
+ }
835
+ if( !aCommand.bWrites && !aCommand.bLocks ){
836
+ return aCommand.fa( project, vsRest, fsStdin )
837
+ }
838
+ return fxWithLock(
839
+ sRoot,
840
+ () => aCommand.fa( project, vsRest, fsStdin ),
841
+ () => aResult(
842
+ 2, "", "the project's files are held by another run\n"
843
+ )
844
+ )
845
+ }
846
+
847
+ module.exports = { aRun }