@skitterbyte/skitterspec-jira 0.0.0-stage → 1.0.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.
Files changed (109) hide show
  1. package/MIGRATION.md +1089 -0
  2. package/README.md +42 -2
  3. package/assets/claude-md-section.md +85 -0
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-connect.md +13 -0
  6. package/assets/commands/spec-live.md +14 -0
  7. package/assets/commands/spec-remote-review.md +37 -0
  8. package/assets/commands/spec-skip.md +24 -0
  9. package/assets/core/SETUP.md +192 -0
  10. package/assets/core/env.config.json.example +56 -0
  11. package/assets/core/env.config.md +554 -0
  12. package/assets/core/gating.config.json.example +4 -0
  13. package/assets/core/gating.config.md +81 -0
  14. package/assets/core/jira.config.json.example +18 -0
  15. package/assets/core/jira.config.md +61 -0
  16. package/assets/hooks/main-guard.cjs +162 -0
  17. package/assets/hooks/review-gate.cjs +146 -0
  18. package/assets/review/page.html +3384 -0
  19. package/assets/rules/commit-trailers.md +96 -0
  20. package/assets/rules/negative-checks.md +69 -0
  21. package/assets/rules/offered-unblocks.md +100 -0
  22. package/assets/rules/spec-planning.md +692 -0
  23. package/assets/rules/spec-reports.md +557 -0
  24. package/assets/skills/no-spec/SKILL.md +246 -0
  25. package/assets/skills/spec/SKILL.md +714 -0
  26. package/assets/skills/spec-bug/SKILL.md +583 -0
  27. package/assets/skills/spec-cancel/SKILL.md +261 -0
  28. package/assets/skills/spec-complete/SKILL.md +448 -0
  29. package/assets/skills/spec-diff/SKILL.md +870 -0
  30. package/assets/skills/spec-hotfix/SKILL.md +605 -0
  31. package/assets/skills/spec-init/SKILL.md +178 -0
  32. package/assets/skills/spec-jira-setup/SKILL.md +220 -0
  33. package/assets/skills/spec-list/SKILL.md +173 -0
  34. package/assets/skills/spec-next/SKILL.md +676 -0
  35. package/assets/skills/spec-push/SKILL.md +306 -0
  36. package/assets/skills/spec-review/SKILL.md +256 -0
  37. package/assets/skills/spec-reviewed/SKILL.md +284 -0
  38. package/assets/skills/spec-start/SKILL.md +496 -0
  39. package/assets/skills/spec-status/SKILL.md +112 -0
  40. package/assets/skills/spec-sync/SKILL.md +218 -0
  41. package/assets/skills/spec-to-main/SKILL.md +139 -0
  42. package/bin/skitterspec-jira.js +61 -0
  43. package/package.json +40 -4
  44. package/src/cli.js +5901 -0
  45. package/src/deprecate.js +138 -0
  46. package/src/env/bootstrap.js +188 -0
  47. package/src/env/building.js +143 -0
  48. package/src/env/classify.js +176 -0
  49. package/src/env/commitcmd.js +108 -0
  50. package/src/env/config.js +753 -0
  51. package/src/env/dev.js +63 -0
  52. package/src/env/hooks.js +226 -0
  53. package/src/env/hotfix.js +385 -0
  54. package/src/env/hotfixland.js +302 -0
  55. package/src/env/integrate.js +126 -0
  56. package/src/env/live.js +504 -0
  57. package/src/env/mainguard.js +299 -0
  58. package/src/env/provision.js +466 -0
  59. package/src/env/proxy.js +192 -0
  60. package/src/env/prune.js +119 -0
  61. package/src/env/registry.js +159 -0
  62. package/src/env/render.js +17 -0
  63. package/src/env/resolve.js +716 -0
  64. package/src/env/review.js +2577 -0
  65. package/src/env/reviewers/coderabbit.js +228 -0
  66. package/src/env/reviewers.js +517 -0
  67. package/src/env/serve.js +1044 -0
  68. package/src/env/supervise.js +157 -0
  69. package/src/env/teardown.js +248 -0
  70. package/src/env/trust.js +87 -0
  71. package/src/gating.js +155 -0
  72. package/src/init.js +1150 -0
  73. package/src/lines-diff.js +114 -0
  74. package/src/prompts.js +129 -0
  75. package/src/vendor/jira/adf.js +414 -0
  76. package/src/vendor/jira/api.js +295 -0
  77. package/src/vendor/jira/cli-sync.js +1256 -0
  78. package/src/vendor/jira/commands.js +44 -0
  79. package/src/vendor/jira/config.js +296 -0
  80. package/src/vendor/jira/credentials.js +108 -0
  81. package/src/vendor/jira/dialect.js +45 -0
  82. package/src/vendor/jira/mcp.js +129 -0
  83. package/src/vendor/provider-kit/index.js +41 -0
  84. package/src/vendor/provider-kit/src/adapter.js +23 -0
  85. package/src/vendor/provider-kit/src/apply.js +197 -0
  86. package/src/vendor/provider-kit/src/args.js +68 -0
  87. package/src/vendor/provider-kit/src/config.js +198 -0
  88. package/src/vendor/provider-kit/src/credentials.js +404 -0
  89. package/src/vendor/provider-kit/src/doctor.js +35 -0
  90. package/src/vendor/provider-kit/src/http.js +44 -0
  91. package/src/vendor/provider-kit/src/identity.js +93 -0
  92. package/src/vendor/provider-kit/src/md-compare.js +39 -0
  93. package/src/vendor/provider-kit/src/md-tables.js +94 -0
  94. package/src/vendor/provider-kit/src/prompt.js +50 -0
  95. package/src/vendor/provider-kit/src/released.js +226 -0
  96. package/src/vendor/provider-kit/src/specs.js +166 -0
  97. package/src/vendor/provider-kit/src/transport.js +22 -0
  98. package/src/vendor/sync-core/index.js +92 -0
  99. package/src/vendor/sync-core/src/base.js +81 -0
  100. package/src/vendor/sync-core/src/compare.js +306 -0
  101. package/src/vendor/sync-core/src/dialect.js +51 -0
  102. package/src/vendor/sync-core/src/fields.js +36 -0
  103. package/src/vendor/sync-core/src/legacy.js +100 -0
  104. package/src/vendor/sync-core/src/normalize.js +989 -0
  105. package/src/vendor/sync-core/src/push.js +85 -0
  106. package/src/vendor/sync-core/src/retarget.js +278 -0
  107. package/src/vendor/sync-core/src/task-block.js +308 -0
  108. package/src/vendor/sync-core/src/verify.js +67 -0
  109. package/src/vendor/sync-core/src/write.js +191 -0
@@ -0,0 +1,404 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The user-level credentials store, shared by every skitterspec provider —
5
+ * where an API credential lives when it is not in the environment.
6
+ *
7
+ * A repo's committed provider config deliberately holds only the NAME of an
8
+ * env var, never a credential. This store is the alternative: one file per
9
+ * machine, outside every repo, at
10
+ * `$XDG_CONFIG_HOME/skitterspec/credentials.json` (else `~/.config/…`).
11
+ *
12
+ * TWO SCHEMAS, one file. v1 — `{ version: 1, teams: { <scopeId>: entry } }` —
13
+ * predates providers being plural, so every v1 entry belongs to the one
14
+ * provider that existed then (the factory names it as `legacyV1Owner`). v2
15
+ * namespaces: `{ version: 2, providers: { <name>: { teams: {…} } } }`. Reads
16
+ * understand both. Writes by the legacy owner keep a v1 store v1 — older CLIs
17
+ * still read it, and its tests pin the shape — while the FIRST write by any
18
+ * other provider migrates the file to v2, folding the v1 entries under the
19
+ * legacy owner. That is the moment two providers actually share the file, and
20
+ * before it nothing changes shape underneath anyone.
21
+ *
22
+ * An entry's `key` may be a string (a bare API key) or an object (a compound
23
+ * credential such as email + token) — the store passes it through opaquely.
24
+ *
25
+ * Reads never throw: every failure returns a structured reason, and a missing
26
+ * store is the normal default, not a problem. Nothing here ever returns a key
27
+ * inside an error, and callers must keep it out of logs, plans, snapshots and
28
+ * stamped frontmatter.
29
+ */
30
+
31
+ const { spawnSync } = require('node:child_process')
32
+ const fs = require('node:fs')
33
+ const os = require('node:os')
34
+ const path = require('node:path')
35
+
36
+ const DIR_NAME = 'skitterspec'
37
+ const FILE_NAME = 'credentials.json'
38
+
39
+ // Modes wider than owner-only. Checked ssh-style: a store other users can read
40
+ // is refused rather than read, because silently using it would hide the leak,
41
+ // and silently chmod'ing someone's file is not ours to do.
42
+ const GROUP_OR_WORLD = 0o077
43
+
44
+ // 60s: a password manager may prompt for biometric or a master password, and a
45
+ // hang here would look like the CLI itself wedging.
46
+ const COMMAND_TIMEOUT_MS = 60_000
47
+
48
+ /**
49
+ * Absolute path of the store. Honours `$XDG_CONFIG_HOME`, else `~/.config`.
50
+ * `env` and `homedir` are injected so tests never touch a real home directory.
51
+ */
52
+ function storePath(env = process.env, homedir = os.homedir) {
53
+ const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.trim()
54
+ const base = xdg || path.join(homedir(), '.config')
55
+ return path.join(base, DIR_NAME, FILE_NAME)
56
+ }
57
+
58
+ /**
59
+ * Read the store (schema-agnostic).
60
+ *
61
+ * @returns {object} `{ ok: true, store, path }` — parsed and owner-only;
62
+ * `{ ok: false, reason, code, path }` otherwise. `code` is one of:
63
+ * `absent` (no file — the normal default, not a problem), `permissions`
64
+ * (group/world readable), `unreadable`, `malformed`.
65
+ */
66
+ function readStore(file, { stat = fs.statSync, read = fs.readFileSync } = {}) {
67
+ let info
68
+ try {
69
+ info = stat(file)
70
+ } catch (err) {
71
+ if (err.code === 'ENOENT') {
72
+ return { ok: false, code: 'absent', path: file, reason: `no credentials store at ${file}` }
73
+ }
74
+ return { ok: false, code: 'unreadable', path: file, reason: `cannot read ${file}: ${err.message}` }
75
+ }
76
+
77
+ if (info.mode & GROUP_OR_WORLD) {
78
+ const mode = (info.mode & 0o777).toString(8)
79
+ return {
80
+ ok: false,
81
+ code: 'permissions',
82
+ path: file,
83
+ reason:
84
+ `credentials store ${file} is mode ${mode} — readable by other users.\n` +
85
+ ` Run: chmod 600 ${file}`,
86
+ }
87
+ }
88
+
89
+ let raw
90
+ try {
91
+ raw = read(file, 'utf-8')
92
+ } catch (err) {
93
+ return { ok: false, code: 'unreadable', path: file, reason: `cannot read ${file}: ${err.message}` }
94
+ }
95
+
96
+ let store
97
+ try {
98
+ store = JSON.parse(raw)
99
+ } catch (err) {
100
+ return { ok: false, code: 'malformed', path: file, reason: `invalid JSON in ${file}: ${err.message}` }
101
+ }
102
+ if (!store || typeof store !== 'object' || Array.isArray(store)) {
103
+ return { ok: false, code: 'malformed', path: file, reason: `invalid credentials store in ${file}` }
104
+ }
105
+ return { ok: true, store, path: file }
106
+ }
107
+
108
+ /** The store's permission bits as an octal string, or null when absent. */
109
+ function storeMode(file, deps = {}) {
110
+ const stat = deps.stat || fs.statSync
111
+ try {
112
+ return (stat(file).mode & 0o777).toString(8)
113
+ } catch {
114
+ return null
115
+ }
116
+ }
117
+
118
+ // Last 4 characters, for reporting that a key exists without revealing it.
119
+ // Compound (object) credentials have no meaningful tail — null, never a dump.
120
+ function fingerprint(key) {
121
+ if (typeof key !== 'string' || !key) return null
122
+ return `…${key.slice(-4)}`
123
+ }
124
+
125
+ // The provider's scope map (`teams`) inside a parsed store, resolving both
126
+ // schemas. Returns `null` when the store holds nothing for this provider —
127
+ // which for a v1 store is every provider except the legacy owner.
128
+ function scopeMapOf(store, provider, legacyV1Owner) {
129
+ if (!store || typeof store !== 'object') return null
130
+ if (store.providers && typeof store.providers === 'object') {
131
+ const entry = store.providers[provider]
132
+ return entry && typeof entry.teams === 'object' ? entry.teams : null
133
+ }
134
+ if (provider !== legacyV1Owner) return null
135
+ return store.teams && typeof store.teams === 'object' ? store.teams : null
136
+ }
137
+
138
+ function defaultRunCommand(command, timeout) {
139
+ const r = spawnSync('sh', ['-c', command], { encoding: 'utf-8', timeout })
140
+ if (r.error) return { ok: false, error: r.error.message }
141
+ if (r.status !== 0) {
142
+ // stderr only — stdout is the secret, and must not reach an error message.
143
+ const detail = (r.stderr || '').trim().split('\n')[0] || `exit ${r.status}`
144
+ return { ok: false, error: detail }
145
+ }
146
+ return { ok: true, stdout: r.stdout }
147
+ }
148
+
149
+ /**
150
+ * A store bound to one provider. `legacyV1Owner` names the provider whose
151
+ * entries a v1 store holds — the one that predates the namespace; a provider
152
+ * IS that owner exactly when the names match.
153
+ */
154
+ function makeCredentialsStore({ provider, legacyV1Owner }) {
155
+ if (!provider) throw new Error('makeCredentialsStore: provider name required')
156
+ const ownsV1 = provider === legacyV1Owner
157
+
158
+ const mapOf = (store) => scopeMapOf(store, provider, legacyV1Owner)
159
+
160
+ function entryFor(store, scopeId) {
161
+ if (!scopeId) return null
162
+ const map = mapOf(store)
163
+ const entry = map ? map[scopeId] : null
164
+ return entry && typeof entry === 'object' ? entry : null
165
+ }
166
+
167
+ function keyFor(store, scopeId) {
168
+ const entry = entryFor(store, scopeId)
169
+ if (!entry) return null
170
+ const key = entry.key
171
+ if (typeof key === 'string') return key.trim() || null
172
+ return null
173
+ }
174
+
175
+ // A compound (object) credential, for providers whose auth is more than one
176
+ // string. Strings resolve through `keyFor`; this never trims or reshapes.
177
+ function credentialFor(store, scopeId) {
178
+ const entry = entryFor(store, scopeId)
179
+ if (!entry) return null
180
+ return entry.key && typeof entry.key === 'object' ? entry.key : null
181
+ }
182
+
183
+ function userFor(store, scopeId) {
184
+ const entry = entryFor(store, scopeId)
185
+ if (!entry) return null
186
+ const user = entry.user
187
+ if (!user || typeof user !== 'object') return null
188
+ // The id is what an assignment is actually made with, so an entry without
189
+ // one is not a partial identity — it is no identity at all.
190
+ const id = typeof user.id === 'string' && user.id.trim() ? user.id.trim() : null
191
+ if (!id) return null
192
+ const name = typeof user.name === 'string' && user.name.trim() ? user.name.trim() : null
193
+ return { id, name }
194
+ }
195
+
196
+ /**
197
+ * Resolve one scope's key from the store: a stored `key` first, else running
198
+ * its `keyCommand` and taking stdout.
199
+ *
200
+ * `keyCommand` is honoured ONLY from this user-level store — never from a
201
+ * repo's committed config, which travels with the repo and would let a cloned
202
+ * repo run arbitrary code. A command that fails, times out or prints nothing
203
+ * resolves to no key — the ordinary "fall back" state — but its stderr is
204
+ * carried back so a broken command is diagnosable. Its **stdout is never**
205
+ * put in an error: that is the key.
206
+ */
207
+ function resolveKey(store, scopeId, deps = {}) {
208
+ const direct = keyFor(store, scopeId)
209
+ if (direct) return { key: direct, source: 'store' }
210
+
211
+ const entry = entryFor(store, scopeId)
212
+ const command = entry && typeof entry.keyCommand === 'string' ? entry.keyCommand.trim() : ''
213
+ if (!command) return { key: null, source: null }
214
+
215
+ const run = deps.run || defaultRunCommand
216
+ const result = run(command, deps.timeoutMs || COMMAND_TIMEOUT_MS)
217
+ if (!result.ok) {
218
+ return { key: null, source: null, command, reason: `keyCommand failed: ${result.error}` }
219
+ }
220
+ const key = (result.stdout || '').trim()
221
+ if (!key) {
222
+ return { key: null, source: null, command, reason: 'keyCommand produced no output' }
223
+ }
224
+ return { key, source: 'command', command }
225
+ }
226
+
227
+ // Read-modify-write of this provider's scope map, with the schema rules from
228
+ // the module header. `mutate(entry)` returns the scope's next entry, or the
229
+ // literal string 'delete' to drop it; `mutate` receives the current entry (or
230
+ // null). Refuses on an unreadable or over-permissive store, exactly like a
231
+ // read — silently rewriting a world-readable file would leave the leak.
232
+ function mutateEntry(file, scopeId, mutate, deps = {}) {
233
+ const mkdir = deps.mkdir || fs.mkdirSync
234
+ const write = deps.write || fs.writeFileSync
235
+ const chmod = deps.chmod || fs.chmodSync
236
+ const exists = deps.exists || fs.existsSync
237
+
238
+ const created = !exists(file)
239
+ let store = null
240
+ if (!created) {
241
+ const current = readStore(file, deps)
242
+ if (!current.ok) return { ok: false, reason: current.reason, code: current.code }
243
+ store = current.store
244
+ }
245
+
246
+ // Decide the shape this write leaves behind.
247
+ const isV2 = !!(store && store.providers && typeof store.providers === 'object')
248
+ if (!store) {
249
+ store = ownsV1 ? { version: 1, teams: {} } : { version: 2, providers: {} }
250
+ } else if (!isV2 && !ownsV1) {
251
+ // The first write by a second provider: fold the v1 entries under their
252
+ // owner and namespace the file. This is the one moment the shape changes.
253
+ const legacyTeams = store.teams && typeof store.teams === 'object' ? store.teams : {}
254
+ store = { version: 2, providers: { [legacyV1Owner]: { teams: legacyTeams } } }
255
+ }
256
+
257
+ let map
258
+ if (store.providers && typeof store.providers === 'object') {
259
+ if (!store.providers[provider] || typeof store.providers[provider] !== 'object') {
260
+ store.providers[provider] = { teams: {} }
261
+ }
262
+ if (!store.providers[provider].teams || typeof store.providers[provider].teams !== 'object') {
263
+ store.providers[provider].teams = {}
264
+ }
265
+ if (!store.version || store.version < 2) store.version = 2
266
+ map = store.providers[provider].teams
267
+ } else {
268
+ if (!store.teams || typeof store.teams !== 'object') store.teams = {}
269
+ if (!store.version) store.version = 1
270
+ map = store.teams
271
+ }
272
+
273
+ const current = map[scopeId] && typeof map[scopeId] === 'object' ? map[scopeId] : null
274
+ const next = mutate(current)
275
+ if (next === 'delete') {
276
+ if (!current) return { ok: true, path: file, created: false, removed: false }
277
+ delete map[scopeId]
278
+ } else if (next !== undefined) {
279
+ map[scopeId] = next
280
+ }
281
+
282
+ mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
283
+ write(file, JSON.stringify(store, null, 2) + '\n', { mode: 0o600 })
284
+ // `mode` on writeFileSync only applies when the file is CREATED, so an
285
+ // existing file keeps its mode — narrow it explicitly.
286
+ chmod(file, 0o600)
287
+ return { ok: true, path: file, created, removed: next === 'delete' }
288
+ }
289
+
290
+ /** Record a credential (string key or compound object) for one scope. */
291
+ function writeKey(file, scopeId, key, deps = {}) {
292
+ if (!scopeId) return { ok: false, reason: 'no team id — nothing to key the entry by' }
293
+ const isCompound = key && typeof key === 'object'
294
+ const value = isCompound ? key : typeof key === 'string' ? key.trim() : ''
295
+ if (!value || (isCompound && !Object.keys(value).length)) {
296
+ return { ok: false, reason: 'empty key — nothing stored' }
297
+ }
298
+ return mutateEntry(file, scopeId, (entry) => ({ ...(entry || {}), key: value }), deps)
299
+ }
300
+
301
+ /** Record the identity for one scope, preserving its credential. */
302
+ function writeUser(file, scopeId, user, deps = {}) {
303
+ if (!scopeId) return { ok: false, reason: 'no team id — nothing to key the entry by' }
304
+ const id = user && typeof user.id === 'string' ? user.id.trim() : ''
305
+ if (!id) return { ok: false, reason: 'no user id — nothing stored' }
306
+ const name = user && typeof user.name === 'string' ? user.name.trim() : ''
307
+ return mutateEntry(
308
+ file,
309
+ scopeId,
310
+ (entry) => ({ ...(entry || {}), user: name ? { id, name } : { id } }),
311
+ deps,
312
+ )
313
+ }
314
+
315
+ /**
316
+ * Record a command for one scope instead of a key. Unlike a key, a command is
317
+ * not a secret, so this one IS safe to pass as an argument. `literalKeyRe`
318
+ * (with `literalKeyAdvice`) is the provider's own key shape — a command with
319
+ * a key written into it is refused at the door, because a command is
320
+ * displayed by `credentials status` and never treated as a secret.
321
+ */
322
+ function writeKeyCommand(file, scopeId, command, deps = {}, { literalKeyRe, literalKeyAdvice } = {}) {
323
+ if (!scopeId) return { ok: false, reason: 'no team id — nothing to key the entry by' }
324
+ if (typeof command !== 'string' || !command.trim()) {
325
+ return { ok: false, reason: 'empty command — nothing stored' }
326
+ }
327
+ if (literalKeyRe && literalKeyRe.test(command)) {
328
+ return { ok: false, reason: literalKeyAdvice || 'that command has a key written into it.' }
329
+ }
330
+ // A command REPLACES a stored key for that scope — keeping both would mean
331
+ // the key silently wins and the command never runs.
332
+ return mutateEntry(file, scopeId, () => ({ keyCommand: command.trim() }), deps)
333
+ }
334
+
335
+ /** Remove one scope's entry entirely. Absent store or entry is a clean no-op. */
336
+ function removeKey(file, scopeId, deps = {}) {
337
+ const current = readStore(file, deps)
338
+ if (!current.ok) {
339
+ if (current.code === 'absent') return { ok: true, path: file, removed: false }
340
+ return { ok: false, reason: current.reason, code: current.code }
341
+ }
342
+ if (!entryFor(current.store, scopeId)) return { ok: true, path: file, removed: false }
343
+ const r = mutateEntry(file, scopeId, () => 'delete', deps)
344
+ return r.ok ? { ok: true, path: file, removed: true } : r
345
+ }
346
+
347
+ /**
348
+ * Forget the identity recorded for one scope, leaving its KEY in place.
349
+ * Deliberately not `removeKey`'s shape: that one drops the whole entry, which
350
+ * is right when the key is being revoked; here it would sign the user out
351
+ * entirely because they corrected their own name.
352
+ */
353
+ function removeUser(file, scopeId, deps = {}) {
354
+ const current = readStore(file, deps)
355
+ if (!current.ok) {
356
+ if (current.code === 'absent') return { ok: true, path: file, removed: false }
357
+ return { ok: false, reason: current.reason, code: current.code }
358
+ }
359
+ const entry = entryFor(current.store, scopeId)
360
+ if (!entry || !entry.user) return { ok: true, path: file, removed: false }
361
+ const r = mutateEntry(
362
+ file,
363
+ scopeId,
364
+ (e) => {
365
+ const next = { ...(e || {}) }
366
+ delete next.user
367
+ return next
368
+ },
369
+ deps,
370
+ )
371
+ return r.ok ? { ok: true, path: file, removed: true } : r
372
+ }
373
+
374
+ return {
375
+ provider,
376
+ storePath,
377
+ readStore,
378
+ storeMode,
379
+ fingerprint,
380
+ entryFor,
381
+ keyFor,
382
+ credentialFor,
383
+ userFor,
384
+ resolveKey,
385
+ writeKey,
386
+ writeUser,
387
+ writeKeyCommand,
388
+ removeKey,
389
+ removeUser,
390
+ }
391
+ }
392
+
393
+ module.exports = {
394
+ makeCredentialsStore,
395
+ storePath,
396
+ readStore,
397
+ storeMode,
398
+ fingerprint,
399
+ defaultRunCommand,
400
+ COMMAND_TIMEOUT_MS,
401
+ DIR_NAME,
402
+ FILE_NAME,
403
+ GROUP_OR_WORLD,
404
+ }
@@ -0,0 +1,35 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The doctor REPORT FRAMEWORK — the states, the row shape, and the combining
5
+ * rule every provider's `doctor` shares. The checks themselves are the
6
+ * provider's: they name its config files, its states, its transports.
7
+ *
8
+ * Two distinctions carry the design:
9
+ *
10
+ * 1. **`missing` is not `broken`.** `missing` is an opt-in not taken, which is
11
+ * fine; `broken` is configured-but-wrong, which is not. `ok` is false only
12
+ * for `broken`, so declining an opt-in never reads as a failure.
13
+ * 2. **Every non-`ok` row names the command that fixes it**, so the output is
14
+ * actionable without reading docs.
15
+ *
16
+ * `warn` sits between `ok` and `broken`: worth a human's attention, but not a
17
+ * failure — said out loud, exit code left alone.
18
+ */
19
+
20
+ const DOCTOR_STATES = ['ok', 'missing', 'broken', 'skipped', 'warn']
21
+
22
+ // A row is a check. `fix` is the exact command to run, or null when there is
23
+ // nothing to fix.
24
+ function doctorRow(id, label, state, detail, fix = null) {
25
+ if (!DOCTOR_STATES.includes(state)) throw new Error(`doctor: unknown check state "${state}" for ${id}`)
26
+ return { id, label, state, detail, fix }
27
+ }
28
+
29
+ // `missing` is a declined opt-in, so it must not fail the run. Only a
30
+ // configured-but-wrong layer does.
31
+ function combineDoctorChecks(checks) {
32
+ return { ok: !checks.some((c) => c.state === 'broken'), checks }
33
+ }
34
+
35
+ module.exports = { DOCTOR_STATES, doctorRow, combineDoctorChecks }
@@ -0,0 +1,44 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Rate-limit-aware HTTP retry — the transport discipline every provider's API
5
+ * client shares, with none of any tracker's protocol in it.
6
+ *
7
+ * A bulk backfill is exactly the workload that gets throttled, and failing the
8
+ * run would strand a half-mirrored bucket — so a 429 waits and retries rather
9
+ * than surfacing. The server says how long to wait (`Retry-After`, seconds);
10
+ * honour that over guessing, and fall back to exponential backoff (from 1s,
11
+ * capped) when it doesn't.
12
+ */
13
+
14
+ const MAX_RETRIES = 5
15
+ const MAX_BACKOFF_MS = 60_000
16
+
17
+ // How long to wait before retrying a 429.
18
+ function retryDelay(res, attempt, maxBackoffMs = MAX_BACKOFF_MS) {
19
+ const header = res.headers && typeof res.headers.get === 'function' ? res.headers.get('retry-after') : null
20
+ const seconds = header != null ? Number(header) : NaN
21
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, maxBackoffMs)
22
+ return Math.min(1000 * 2 ** attempt, maxBackoffMs)
23
+ }
24
+
25
+ /**
26
+ * Run `attempt()` (→ a fetch Response) until it is not rate-limited or the
27
+ * retries run out. Returns `{ response, exhausted }` — `exhausted` true when
28
+ * the LAST response is still a 429, so the caller phrases its own refusal (and
29
+ * marks it retryable: rate limiting is the failure where waiting is the whole
30
+ * answer, and the one a refusal must stay distinguishable from).
31
+ */
32
+ async function withRateLimitRetry(attempt, { sleep, maxRetries = MAX_RETRIES, maxBackoffMs = MAX_BACKOFF_MS } = {}) {
33
+ const wait = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
34
+ for (let n = 0; ; n++) {
35
+ const response = await attempt()
36
+ if (response.status === 429 && n < maxRetries) {
37
+ await wait(retryDelay(response, n, maxBackoffMs))
38
+ continue
39
+ }
40
+ return { response, exhausted: response.status === 429 }
41
+ }
42
+ }
43
+
44
+ module.exports = { withRateLimitRetry, retryDelay, MAX_RETRIES, MAX_BACKOFF_MS }
@@ -0,0 +1,93 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * "Who am I in this workspace?" — the resolution shape every provider's
5
+ * assignment rests on.
6
+ *
7
+ * Three tiers, first hit wins:
8
+ *
9
+ * 1. the user-level credentials store (`source: 'store'`) — no network
10
+ * 2. the credential itself, via the tracker's "who am I" call
11
+ * (`source: 'viewer'`)
12
+ * 3. nothing (`source: 'unknown'`)
13
+ *
14
+ * **Unknown is a normal state, not a failure.** A shared/bot key answers the
15
+ * viewer call with the bot; an offline machine answers with nothing; neither is
16
+ * a broken install. So this returns a structured result the caller branches on,
17
+ * never an exception — and it NEVER PROMPTS and NEVER WRITES. Prompting belongs
18
+ * to the skill with a human in front of it, and caching belongs to the command
19
+ * that decided the answer was worth keeping: a resolver that wrote to disk as a
20
+ * side effect would cache a bot's identity the first time CI ran.
21
+ *
22
+ * Everything tracker-shaped arrives as a hook: how a credential resolves, how
23
+ * the viewer is fetched, and how a user object yields a display name.
24
+ */
25
+
26
+ /**
27
+ * @param {object} hooks
28
+ * - `scopeId` the credentials-store scope (e.g. a team id) — '' skips tier 1
29
+ * - `file` the store path (carried through on every result)
30
+ * - `readStore(file)` kit credentials read
31
+ * - `userFor(store,id)` the provider store binding's identity read
32
+ * - `resolveCredential()` → `{ ok, error?, envVar?, … }` — the provider's key resolution
33
+ * - `readViewer(credential)` → the tracker's current user (may throw)
34
+ * - `nameOf(viewer)` display name of a user object
35
+ * @returns {Promise<object>} `{ ok: true, id, name, email?, source, path }` or
36
+ * `{ ok: false, source: 'unknown', reason, envVar?, path }`.
37
+ */
38
+ async function resolveIdentity({ scopeId, file, readStore, userFor, resolveCredential, readViewer, nameOf }) {
39
+ if (scopeId) {
40
+ const result = readStore(file)
41
+ if (result.ok) {
42
+ const cached = userFor(result.store, scopeId)
43
+ if (cached) return { ok: true, id: cached.id, name: cached.name, source: 'store', path: file }
44
+ }
45
+ // A store that is present but unusable (over-permissive, malformed) is NOT
46
+ // reported as an identity failure — credential resolution hits the same
47
+ // file and reports it properly. Duplicating it here would print the same
48
+ // permissions warning twice for one cause.
49
+ }
50
+
51
+ const credential = resolveCredential()
52
+ if (!credential.ok) {
53
+ return { ok: false, source: 'unknown', reason: credential.error, envVar: credential.envVar, path: file }
54
+ }
55
+
56
+ let viewer
57
+ try {
58
+ viewer = await readViewer(credential)
59
+ } catch (error) {
60
+ // Offline, revoked key, tracker down. All are "cannot tell", which routes
61
+ // to the harmless branch: the caller skips assignment rather than guessing.
62
+ return { ok: false, source: 'unknown', reason: error.message, path: file }
63
+ }
64
+
65
+ const id = viewer && typeof viewer.id === 'string' ? viewer.id.trim() : ''
66
+ if (!id) {
67
+ return { ok: false, source: 'unknown', reason: 'the tracker returned no viewer for this key', path: file }
68
+ }
69
+ return {
70
+ ok: true,
71
+ id,
72
+ name: (nameOf || defaultDisplayName)(viewer),
73
+ email: (viewer && viewer.email) || null,
74
+ source: 'viewer',
75
+ path: file,
76
+ }
77
+ }
78
+
79
+ /**
80
+ * The name to show for a tracker user. Falls back through `displayName` and
81
+ * `email` so a user with an odd profile still reads as a person rather than a
82
+ * UUID.
83
+ */
84
+ function defaultDisplayName(user) {
85
+ if (!user || typeof user !== 'object') return null
86
+ for (const field of ['name', 'displayName', 'email']) {
87
+ const value = user[field]
88
+ if (typeof value === 'string' && value.trim()) return value.trim()
89
+ }
90
+ return null
91
+ }
92
+
93
+ module.exports = { resolveIdentity, defaultDisplayName }
@@ -0,0 +1,39 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The markdown-form reduction both dialects compare through. Every transform
5
+ * here is a benign difference between two spellings of the same markdown —
6
+ * a reserialising editor's (Linear rewrites bullets, renumbers lists,
7
+ * case-folds checkboxes, collapses separator rows on save) or a round-trip
8
+ * renderer's (the ADF reducer re-emits `-` bullets and `| -- |` separators
9
+ * whatever the source used). Normalised away BEFORE comparing, so an intact
10
+ * mirror never reports as data loss; digits stay significant everywhere else —
11
+ * a port, a version, a key length still count.
12
+ */
13
+
14
+ function canonicalForCompare(text) {
15
+ return String(text == null ? '' : text)
16
+ .replace(/\r\n/g, '\n')
17
+ .split('\n')
18
+ .map((line) =>
19
+ line
20
+ // Ordered-list markers → a placeholder. Linear renumbers lists, and the
21
+ // digits it rewrites are alphanumeric, so a naive alphanumeric compare
22
+ // would flag its own benign reformat as data loss. Normalising the
23
+ // marker keeps digits significant EVERYWHERE ELSE — a port, a version, a
24
+ // key length still count.
25
+ .replace(/^(\s*)\d+\.(\s)/, '$1#.$2')
26
+ // Unordered markers unify (`-`/`+` come back as `*`).
27
+ .replace(/^(\s*)[*+-](\s)/, '$1-$2')
28
+ // Checkbox marks case-fold. Targeted rather than lowercasing the whole
29
+ // text, so a genuine case corruption in prose is still caught.
30
+ .replace(/^(\s*-\s*\[)[xX](\])/, '$1x$2')
31
+ // Table separator rows collapse (`|-------|` → `| -- |`).
32
+ .replace(/^\s*\|[\s:|-]+\|\s*$/, '|--|')
33
+ .replace(/[ \t]+$/, ''),
34
+ )
35
+ .join('\n')
36
+ .replace(/\n{3,}/g, '\n\n')
37
+ .trim()
38
+ }
39
+ module.exports = { markdownCanonicalForCompare: canonicalForCompare }