@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.
Files changed (113) hide show
  1. package/MIGRATION.md +1266 -0
  2. package/README.md +44 -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 +606 -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 +88 -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 +702 -0
  23. package/assets/rules/spec-reports.md +572 -0
  24. package/assets/skills/no-spec/SKILL.md +249 -0
  25. package/assets/skills/spec/SKILL.md +714 -0
  26. package/assets/skills/spec-bug/SKILL.md +585 -0
  27. package/assets/skills/spec-cancel/SKILL.md +261 -0
  28. package/assets/skills/spec-complete/SKILL.md +451 -0
  29. package/assets/skills/spec-diff/SKILL.md +870 -0
  30. package/assets/skills/spec-hotfix/SKILL.md +607 -0
  31. package/assets/skills/spec-init/SKILL.md +178 -0
  32. package/assets/skills/spec-jira-setup/SKILL.md +221 -0
  33. package/assets/skills/spec-list/SKILL.md +173 -0
  34. package/assets/skills/spec-next/SKILL.md +678 -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 +114 -0
  40. package/assets/skills/spec-sync/SKILL.md +220 -0
  41. package/assets/skills/spec-to-main/SKILL.md +141 -0
  42. package/bin/skitterspec-jira.js +68 -0
  43. package/package.json +40 -4
  44. package/src/cli.js +5967 -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 +850 -0
  51. package/src/env/dev.js +63 -0
  52. package/src/env/glob.js +34 -0
  53. package/src/env/hooks.js +226 -0
  54. package/src/env/hotfix.js +413 -0
  55. package/src/env/hotfixland.js +302 -0
  56. package/src/env/integrate.js +167 -0
  57. package/src/env/live.js +479 -0
  58. package/src/env/mainguard.js +299 -0
  59. package/src/env/provision.js +466 -0
  60. package/src/env/proxy.js +225 -0
  61. package/src/env/prune.js +119 -0
  62. package/src/env/registry.js +159 -0
  63. package/src/env/render.js +17 -0
  64. package/src/env/resolve.js +716 -0
  65. package/src/env/review.js +2622 -0
  66. package/src/env/reviewers/coderabbit.js +228 -0
  67. package/src/env/reviewers.js +517 -0
  68. package/src/env/serve.js +1044 -0
  69. package/src/env/supervise.js +157 -0
  70. package/src/env/teardown.js +248 -0
  71. package/src/env/trust.js +87 -0
  72. package/src/gating.js +155 -0
  73. package/src/init.js +1150 -0
  74. package/src/lines-diff.js +114 -0
  75. package/src/prompts.js +129 -0
  76. package/src/vendor/jira/adf.js +414 -0
  77. package/src/vendor/jira/api.js +295 -0
  78. package/src/vendor/jira/cli-sync.js +1252 -0
  79. package/src/vendor/jira/commands.js +44 -0
  80. package/src/vendor/jira/config.js +286 -0
  81. package/src/vendor/jira/credentials.js +108 -0
  82. package/src/vendor/jira/dialect.js +45 -0
  83. package/src/vendor/jira/mcp.js +129 -0
  84. package/src/vendor/provider-kit/index.js +41 -0
  85. package/src/vendor/provider-kit/src/adapter.js +23 -0
  86. package/src/vendor/provider-kit/src/apply.js +207 -0
  87. package/src/vendor/provider-kit/src/args.js +68 -0
  88. package/src/vendor/provider-kit/src/assignment.js +277 -0
  89. package/src/vendor/provider-kit/src/config.js +251 -0
  90. package/src/vendor/provider-kit/src/credentials.js +404 -0
  91. package/src/vendor/provider-kit/src/doctor.js +35 -0
  92. package/src/vendor/provider-kit/src/http.js +63 -0
  93. package/src/vendor/provider-kit/src/identity.js +93 -0
  94. package/src/vendor/provider-kit/src/list-assignee.js +71 -0
  95. package/src/vendor/provider-kit/src/list-rows.js +35 -0
  96. package/src/vendor/provider-kit/src/md-compare.js +39 -0
  97. package/src/vendor/provider-kit/src/md-tables.js +94 -0
  98. package/src/vendor/provider-kit/src/prompt.js +50 -0
  99. package/src/vendor/provider-kit/src/released.js +219 -0
  100. package/src/vendor/provider-kit/src/specs.js +248 -0
  101. package/src/vendor/provider-kit/src/transport.js +22 -0
  102. package/src/vendor/sync-core/index.js +92 -0
  103. package/src/vendor/sync-core/src/base.js +81 -0
  104. package/src/vendor/sync-core/src/compare.js +338 -0
  105. package/src/vendor/sync-core/src/dialect.js +51 -0
  106. package/src/vendor/sync-core/src/fields.js +36 -0
  107. package/src/vendor/sync-core/src/legacy.js +100 -0
  108. package/src/vendor/sync-core/src/normalize.js +971 -0
  109. package/src/vendor/sync-core/src/push.js +85 -0
  110. package/src/vendor/sync-core/src/retarget.js +278 -0
  111. package/src/vendor/sync-core/src/task-block.js +308 -0
  112. package/src/vendor/sync-core/src/verify.js +67 -0
  113. 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 }