@debonet/idd 0.0.0-stage → 0.1.1

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