@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.
- package/MIGRATION.md +1089 -0
- package/README.md +42 -2
- package/assets/claude-md-section.md +85 -0
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-connect.md +13 -0
- package/assets/commands/spec-live.md +14 -0
- package/assets/commands/spec-remote-review.md +37 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/SETUP.md +192 -0
- package/assets/core/env.config.json.example +56 -0
- package/assets/core/env.config.md +554 -0
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/core/jira.config.json.example +18 -0
- package/assets/core/jira.config.md +61 -0
- package/assets/hooks/main-guard.cjs +162 -0
- package/assets/hooks/review-gate.cjs +146 -0
- package/assets/review/page.html +3384 -0
- package/assets/rules/commit-trailers.md +96 -0
- package/assets/rules/negative-checks.md +69 -0
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +692 -0
- package/assets/rules/spec-reports.md +557 -0
- package/assets/skills/no-spec/SKILL.md +246 -0
- package/assets/skills/spec/SKILL.md +714 -0
- package/assets/skills/spec-bug/SKILL.md +583 -0
- package/assets/skills/spec-cancel/SKILL.md +261 -0
- package/assets/skills/spec-complete/SKILL.md +448 -0
- package/assets/skills/spec-diff/SKILL.md +870 -0
- package/assets/skills/spec-hotfix/SKILL.md +605 -0
- package/assets/skills/spec-init/SKILL.md +178 -0
- package/assets/skills/spec-jira-setup/SKILL.md +220 -0
- package/assets/skills/spec-list/SKILL.md +173 -0
- package/assets/skills/spec-next/SKILL.md +676 -0
- package/assets/skills/spec-push/SKILL.md +306 -0
- package/assets/skills/spec-review/SKILL.md +256 -0
- package/assets/skills/spec-reviewed/SKILL.md +284 -0
- package/assets/skills/spec-start/SKILL.md +496 -0
- package/assets/skills/spec-status/SKILL.md +112 -0
- package/assets/skills/spec-sync/SKILL.md +218 -0
- package/assets/skills/spec-to-main/SKILL.md +139 -0
- package/bin/skitterspec-jira.js +61 -0
- package/package.json +40 -4
- package/src/cli.js +5901 -0
- package/src/deprecate.js +138 -0
- package/src/env/bootstrap.js +188 -0
- package/src/env/building.js +143 -0
- package/src/env/classify.js +176 -0
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +753 -0
- package/src/env/dev.js +63 -0
- package/src/env/hooks.js +226 -0
- package/src/env/hotfix.js +385 -0
- package/src/env/hotfixland.js +302 -0
- package/src/env/integrate.js +126 -0
- package/src/env/live.js +504 -0
- package/src/env/mainguard.js +299 -0
- package/src/env/provision.js +466 -0
- package/src/env/proxy.js +192 -0
- package/src/env/prune.js +119 -0
- package/src/env/registry.js +159 -0
- package/src/env/render.js +17 -0
- package/src/env/resolve.js +716 -0
- package/src/env/review.js +2577 -0
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +1044 -0
- package/src/env/supervise.js +157 -0
- package/src/env/teardown.js +248 -0
- package/src/env/trust.js +87 -0
- package/src/gating.js +155 -0
- package/src/init.js +1150 -0
- package/src/lines-diff.js +114 -0
- package/src/prompts.js +129 -0
- package/src/vendor/jira/adf.js +414 -0
- package/src/vendor/jira/api.js +295 -0
- package/src/vendor/jira/cli-sync.js +1256 -0
- package/src/vendor/jira/commands.js +44 -0
- package/src/vendor/jira/config.js +296 -0
- package/src/vendor/jira/credentials.js +108 -0
- package/src/vendor/jira/dialect.js +45 -0
- package/src/vendor/jira/mcp.js +129 -0
- package/src/vendor/provider-kit/index.js +41 -0
- package/src/vendor/provider-kit/src/adapter.js +23 -0
- package/src/vendor/provider-kit/src/apply.js +197 -0
- package/src/vendor/provider-kit/src/args.js +68 -0
- package/src/vendor/provider-kit/src/config.js +198 -0
- package/src/vendor/provider-kit/src/credentials.js +404 -0
- package/src/vendor/provider-kit/src/doctor.js +35 -0
- package/src/vendor/provider-kit/src/http.js +44 -0
- package/src/vendor/provider-kit/src/identity.js +93 -0
- package/src/vendor/provider-kit/src/md-compare.js +39 -0
- package/src/vendor/provider-kit/src/md-tables.js +94 -0
- package/src/vendor/provider-kit/src/prompt.js +50 -0
- package/src/vendor/provider-kit/src/released.js +226 -0
- package/src/vendor/provider-kit/src/specs.js +166 -0
- package/src/vendor/provider-kit/src/transport.js +22 -0
- package/src/vendor/sync-core/index.js +92 -0
- package/src/vendor/sync-core/src/base.js +81 -0
- package/src/vendor/sync-core/src/compare.js +306 -0
- package/src/vendor/sync-core/src/dialect.js +51 -0
- package/src/vendor/sync-core/src/fields.js +36 -0
- package/src/vendor/sync-core/src/legacy.js +100 -0
- package/src/vendor/sync-core/src/normalize.js +989 -0
- package/src/vendor/sync-core/src/push.js +85 -0
- package/src/vendor/sync-core/src/retarget.js +278 -0
- package/src/vendor/sync-core/src/task-block.js +308 -0
- package/src/vendor/sync-core/src/verify.js +67 -0
- 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 }
|