@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.
- package/INSTRUCTIONS.md +159 -0
- package/README.md +89 -2
- package/bin/idd +16 -0
- package/bin/idd-merge-hook +13 -0
- package/docs/existing-page-migration.md +92 -0
- package/docs/hosted-viewer.md +164 -0
- package/package.json +57 -4
- package/scripts/check.js +593 -0
- package/scripts/commands.js +908 -0
- package/scripts/html-text.js +50 -0
- package/scripts/import-page.js +80 -0
- package/scripts/merge-hook.js +365 -0
- package/scripts/page-build.js +21 -0
- package/scripts/project-lock.js +135 -0
- package/specs/adoption.md +133 -0
- package/specs/build.md +201 -0
- package/specs/guidance/agent-merge.md +63 -0
- package/specs/guidance/answers.md +108 -0
- package/specs/guidance/claude-code.md +76 -0
- package/specs/guidance/codex.md +93 -0
- package/specs/guidance/session.md +238 -0
- package/specs/guidance/writing-questions.md +171 -0
- package/specs/hosted-questionnaire.md +793 -0
- package/specs/idd-merge-hook.md +42 -0
- package/specs/idd-page.md +367 -0
- package/specs/project-files.md +198 -0
- package/specs/questions-page.md +169 -0
- package/specs/themes.md +114 -0
- package/templates/questions-page.html +337 -0
|
@@ -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 }
|