@skitterbyte/skitterspec-jira 0.0.0-stage → 2.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 +1266 -0
- package/README.md +44 -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 +606 -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 +88 -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 +702 -0
- package/assets/rules/spec-reports.md +572 -0
- package/assets/skills/no-spec/SKILL.md +249 -0
- package/assets/skills/spec/SKILL.md +714 -0
- package/assets/skills/spec-bug/SKILL.md +585 -0
- package/assets/skills/spec-cancel/SKILL.md +261 -0
- package/assets/skills/spec-complete/SKILL.md +451 -0
- package/assets/skills/spec-diff/SKILL.md +870 -0
- package/assets/skills/spec-hotfix/SKILL.md +607 -0
- package/assets/skills/spec-init/SKILL.md +178 -0
- package/assets/skills/spec-jira-setup/SKILL.md +221 -0
- package/assets/skills/spec-list/SKILL.md +173 -0
- package/assets/skills/spec-next/SKILL.md +678 -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 +114 -0
- package/assets/skills/spec-sync/SKILL.md +220 -0
- package/assets/skills/spec-to-main/SKILL.md +141 -0
- package/bin/skitterspec-jira.js +68 -0
- package/package.json +40 -4
- package/src/cli.js +5967 -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 +850 -0
- package/src/env/dev.js +63 -0
- package/src/env/glob.js +34 -0
- package/src/env/hooks.js +226 -0
- package/src/env/hotfix.js +413 -0
- package/src/env/hotfixland.js +302 -0
- package/src/env/integrate.js +167 -0
- package/src/env/live.js +479 -0
- package/src/env/mainguard.js +299 -0
- package/src/env/provision.js +466 -0
- package/src/env/proxy.js +225 -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 +2622 -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 +1252 -0
- package/src/vendor/jira/commands.js +44 -0
- package/src/vendor/jira/config.js +286 -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 +207 -0
- package/src/vendor/provider-kit/src/args.js +68 -0
- package/src/vendor/provider-kit/src/assignment.js +277 -0
- package/src/vendor/provider-kit/src/config.js +251 -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 +63 -0
- package/src/vendor/provider-kit/src/identity.js +93 -0
- package/src/vendor/provider-kit/src/list-assignee.js +71 -0
- package/src/vendor/provider-kit/src/list-rows.js +35 -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 +219 -0
- package/src/vendor/provider-kit/src/specs.js +248 -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 +338 -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 +971 -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,251 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Config-merge machinery shared by every provider's loader.
|
|
5
|
+
*
|
|
6
|
+
* A provider's config is a committed JSON file merged over its own defaults —
|
|
7
|
+
* only known keys copied (unknown ignored for forward-compat), typed fields
|
|
8
|
+
* validated LOUDLY where a typo would otherwise read as a deliberate choice.
|
|
9
|
+
* The defaults, the file name and the vocabularies are the provider's; the
|
|
10
|
+
* merge and validation shapes are identical across providers, so they live
|
|
11
|
+
* here, bound to the provider by `makeMergeHelpers`.
|
|
12
|
+
*
|
|
13
|
+
* Every thrown message names the provider's own config file, so a refusal
|
|
14
|
+
* points at the file the user actually edits.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
function isObject(value) {
|
|
18
|
+
return value != null && typeof value === 'object' && !Array.isArray(value)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Normalise an array config value to trimmed, non-empty strings.
|
|
22
|
+
function stringList(value) {
|
|
23
|
+
return value.filter((s) => typeof s === 'string' && s.trim()).map((s) => s.trim())
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Copy a typed field from parsed[key] onto base[key] when it matches `type`.
|
|
27
|
+
// Strings are trimmed and must be non-empty to override; `string?` may be empty.
|
|
28
|
+
function assign(base, parsed, key, type) {
|
|
29
|
+
const v = parsed[key]
|
|
30
|
+
if (type === 'string') {
|
|
31
|
+
if (typeof v === 'string' && v.trim()) base[key] = v.trim()
|
|
32
|
+
} else if (type === 'string?') {
|
|
33
|
+
if (typeof v === 'string') base[key] = v
|
|
34
|
+
} else if (type === 'boolean') {
|
|
35
|
+
if (typeof v === 'boolean') base[key] = v
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The provider-bound validators. `configFile` is only used in messages;
|
|
41
|
+
* `ownership` / `lifecycleBuckets` / `phaseMappings` are the provider's own
|
|
42
|
+
* vocabularies (they rarely differ between providers, but they are the
|
|
43
|
+
* provider's to declare).
|
|
44
|
+
*/
|
|
45
|
+
function makeMergeHelpers({ configFile, ownership, lifecycleBuckets, phaseMappings }) {
|
|
46
|
+
// Merge (and validate) sync.fieldOwnership. Any key the caller lists joins
|
|
47
|
+
// the compared field set; the value MUST be one of the ownership modes.
|
|
48
|
+
function mergeFieldOwnership(base, parsed) {
|
|
49
|
+
if (!isObject(parsed)) return
|
|
50
|
+
for (const [field, dir] of Object.entries(parsed)) {
|
|
51
|
+
if (!ownership.includes(dir)) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Invalid ${configFile}: sync.fieldOwnership.${field} = ${JSON.stringify(dir)} ` +
|
|
54
|
+
`(expected one of ${ownership.join('|')})`,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
base[field] = dir
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Merge (and validate) `mapping.phases` in either of its two forms: a scalar
|
|
62
|
+
// mode for the whole repo, or a map of lifecycle bucket → mode. Loud on a bad
|
|
63
|
+
// key or value — a misspelt bucket would otherwise read as a deliberate
|
|
64
|
+
// default and go on minting the sub-issues the config was written to stop.
|
|
65
|
+
function mergePhaseMapping(base, parsed) {
|
|
66
|
+
const value = parsed.phases
|
|
67
|
+
if (typeof value === 'string') {
|
|
68
|
+
if (value.trim()) base.phases = value.trim()
|
|
69
|
+
} else if (isObject(value)) {
|
|
70
|
+
const byBucket = {}
|
|
71
|
+
for (const [bucket, mode] of Object.entries(value)) {
|
|
72
|
+
if (!lifecycleBuckets.includes(bucket)) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Invalid ${configFile}: mapping.phases.${bucket} is not a lifecycle bucket ` +
|
|
75
|
+
`(expected one of ${lifecycleBuckets.join('|')})`,
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
if (!phaseMappings.includes(mode)) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Invalid ${configFile}: mapping.phases.${bucket} = ${JSON.stringify(mode)} ` +
|
|
81
|
+
`(expected one of ${phaseMappings.join('|')})`,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
byBucket[bucket] = mode
|
|
85
|
+
}
|
|
86
|
+
base.phases = byBucket
|
|
87
|
+
} else if (value !== undefined) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Invalid ${configFile}: mapping.phases = ${JSON.stringify(value)} ` +
|
|
90
|
+
`(expected one of ${phaseMappings.join('|')}, or a map of lifecycle bucket to mode)`,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
if (typeof base.phases === 'string' && !phaseMappings.includes(base.phases)) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Invalid ${configFile}: mapping.phases = ${JSON.stringify(base.phases)} ` +
|
|
96
|
+
`(expected one of ${phaseMappings.join('|')}, or a map of lifecycle bucket to mode)`,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Merge (and validate) release.ignorePaths — the path prefixes whose commits
|
|
102
|
+
// are bookkeeping. Loud on anything but an array of non-empty strings: a bad
|
|
103
|
+
// value that quietly fell back would let a project believe it had opted out
|
|
104
|
+
// and go on double-counting tickets. A BLANK entry is rejected rather than
|
|
105
|
+
// dropped — `""` is a prefix of every path, so a stray empty string would
|
|
106
|
+
// silently ignore every commit in the range and report a release as
|
|
107
|
+
// containing nothing: the loudest possible wrong answer, arriving as silence.
|
|
108
|
+
function mergeReleaseIgnorePaths(base, parsed) {
|
|
109
|
+
const value = parsed.ignorePaths
|
|
110
|
+
if (value === undefined) return
|
|
111
|
+
if (!Array.isArray(value)) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Invalid ${configFile}: release.ignorePaths = ${JSON.stringify(value)} ` +
|
|
114
|
+
'(expected an array of repo-relative path prefixes)',
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
value.forEach((entry, i) => {
|
|
118
|
+
if (typeof entry !== 'string' || !entry.trim()) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Invalid ${configFile}: release.ignorePaths[${i}] = ${JSON.stringify(entry)} ` +
|
|
121
|
+
'(expected a non-empty repo-relative path prefix, e.g. "specs/")',
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
base.ignorePaths = stringList(value)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Merge (and validate) sync.keyedFields. Each value is the item's id property
|
|
129
|
+
// name (a non-empty string); a field listed here is compared per item.
|
|
130
|
+
function mergeKeyedFields(base, parsed) {
|
|
131
|
+
if (!isObject(parsed)) return
|
|
132
|
+
for (const [field, idKey] of Object.entries(parsed)) {
|
|
133
|
+
if (typeof idKey !== 'string' || !idKey.trim()) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Invalid ${configFile}: sync.keyedFields.${field} = ${JSON.stringify(idKey)} ` +
|
|
136
|
+
'(expected the item id property name, a non-empty string)',
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
base[field] = idKey.trim()
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Merge (and validate) `states`. A bucket's value is a string — the one state
|
|
144
|
+
// push writes, exactly as it always was — or a non-empty array whose FIRST
|
|
145
|
+
// name is the one push writes and whose rest are read-only aliases: tracker
|
|
146
|
+
// states that also mean this bucket ("Blocked" for in-progress). The first
|
|
147
|
+
// name lands in `states[bucket]`, so every writer is unchanged; the rest go
|
|
148
|
+
// to `stateAliases[bucket]`, read only by code that interprets a remote state.
|
|
149
|
+
//
|
|
150
|
+
// An ALIAS in two buckets is refused: it would make a remote state mean two
|
|
151
|
+
// things, and which one won would be an accident of iteration order. Two
|
|
152
|
+
// buckets sharing one written name stays legal, as it always was — a tracker
|
|
153
|
+
// with no Cancelled state maps cancelled to "Done" — so only a name that an
|
|
154
|
+
// alias contributes is checked.
|
|
155
|
+
function mergeStates(base, parsed) {
|
|
156
|
+
const value = parsed.states
|
|
157
|
+
if (!isObject(value)) return
|
|
158
|
+
const aliases = {}
|
|
159
|
+
for (const bucket of Object.keys(base.states)) {
|
|
160
|
+
const entry = value[bucket]
|
|
161
|
+
if (entry === undefined) continue
|
|
162
|
+
if (typeof entry === 'string') {
|
|
163
|
+
if (entry.trim()) base.states[bucket] = entry.trim()
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
if (!Array.isArray(entry) || !entry.length || entry.some((n) => typeof n !== 'string' || !n.trim())) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Invalid ${configFile}: states.${bucket} = ${JSON.stringify(entry)} ` +
|
|
169
|
+
'(expected a state name, or a non-empty array of them — the first is what push writes)',
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
const names = entry.map((n) => n.trim())
|
|
173
|
+
base.states[bucket] = names[0]
|
|
174
|
+
aliases[bucket] = names.slice(1)
|
|
175
|
+
}
|
|
176
|
+
const owner = new Map()
|
|
177
|
+
for (const bucket of Object.keys(base.states)) {
|
|
178
|
+
const key = base.states[bucket].toLowerCase()
|
|
179
|
+
if (!owner.has(key)) owner.set(key, bucket)
|
|
180
|
+
}
|
|
181
|
+
for (const bucket of Object.keys(aliases)) {
|
|
182
|
+
for (const name of aliases[bucket]) {
|
|
183
|
+
const key = name.toLowerCase()
|
|
184
|
+
if (owner.has(key) && owner.get(key) !== bucket) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`Invalid ${configFile}: states name ${JSON.stringify(name)} is in both ` +
|
|
187
|
+
`${owner.get(key)} and ${bucket} (a tracker state can mean only one bucket)`,
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
owner.set(key, bucket)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
base.stateAliases = aliases
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
mergeStates,
|
|
198
|
+
mergeFieldOwnership,
|
|
199
|
+
mergePhaseMapping,
|
|
200
|
+
mergeReleaseIgnorePaths,
|
|
201
|
+
mergeKeyedFields,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Keys no provider reads any more. The merge already ignores them, as it does
|
|
206
|
+
// every unknown key — so a config still carrying one is a stale file, not a
|
|
207
|
+
// broken one, and `doctor` says so as a `warn` without touching the exit code.
|
|
208
|
+
//
|
|
209
|
+
// WHAT WOULD FOOL THIS: it reads the committed file, so a key set only in a
|
|
210
|
+
// working-tree edit is still reported — which is the file a person would edit.
|
|
211
|
+
const RETIRED_KEYS = Object.freeze([
|
|
212
|
+
{ path: ['releases'], name: 'releases' },
|
|
213
|
+
{ path: ['release', 'stages'], name: 'release.stages' },
|
|
214
|
+
])
|
|
215
|
+
|
|
216
|
+
// The retired keys present in a parsed config. Not an object, or none present,
|
|
217
|
+
// is `[]`: nothing to say is the safe answer to every cannot-tell.
|
|
218
|
+
function retiredKeys(parsed) {
|
|
219
|
+
if (!isObject(parsed)) return []
|
|
220
|
+
return RETIRED_KEYS.filter(({ path }) => {
|
|
221
|
+
let node = parsed
|
|
222
|
+
for (const key of path) {
|
|
223
|
+
if (!isObject(node) || !(key in node)) return false
|
|
224
|
+
node = node[key]
|
|
225
|
+
}
|
|
226
|
+
return true
|
|
227
|
+
}).map((k) => k.name)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// A doctor row naming the retired keys in the config at `file`, or null when
|
|
231
|
+
// there are none — or when the file cannot be read or parsed, which is the
|
|
232
|
+
// loader's to report, not this row's.
|
|
233
|
+
function retiredKeysRow(file, configFile, row) {
|
|
234
|
+
let parsed = null
|
|
235
|
+
try {
|
|
236
|
+
parsed = JSON.parse(require('node:fs').readFileSync(file, 'utf-8'))
|
|
237
|
+
} catch {
|
|
238
|
+
return null
|
|
239
|
+
}
|
|
240
|
+
const names = retiredKeys(parsed)
|
|
241
|
+
if (!names.length) return null
|
|
242
|
+
return row(
|
|
243
|
+
'retired',
|
|
244
|
+
'Retired config',
|
|
245
|
+
'warn',
|
|
246
|
+
`${names.join(', ')} in ${configFile} is no longer read — release and deploy state is the host project's to write (see \`spec-sync released --json\`)`,
|
|
247
|
+
`remove ${names.join(', ')} from ${configFile}`,
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
module.exports = { isObject, stringList, assign, makeMergeHelpers, retiredKeys, retiredKeysRow, RETIRED_KEYS }
|
|
@@ -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 }
|