@skitterbyte/skitterspec-linear 10.2.0 → 10.4.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/assets/core/linear.config.md +53 -0
- package/assets/skills/spec-linear-setup/SKILL.md +23 -3
- package/assets/skills/spec-sync/SKILL.md +177 -0
- package/bin/skitterspec-linear.js +27 -13
- package/package.json +1 -1
- package/src/cli.js +25 -2
- package/src/init.js +15 -5
- package/src/vendor/linear/api.js +62 -8
- package/src/vendor/linear/cli-sync.js +435 -10
- package/src/vendor/linear/commands.js +54 -0
- package/src/vendor/linear/credentials.js +299 -0
- package/src/vendor/linear/doctor.js +341 -0
- package/src/vendor/sync-core/src/compare.js +4 -2
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The user-level credentials store — where a Linear API key lives when it is
|
|
5
|
+
* not in the environment.
|
|
6
|
+
*
|
|
7
|
+
* The repo's `specs/.core/linear.config.json` is COMMITTED and deliberately
|
|
8
|
+
* holds only the NAME of an env var, never a key. That is what makes it safe to
|
|
9
|
+
* share, so a key can never go there. This store is the alternative: one file
|
|
10
|
+
* per machine, outside every repo, at
|
|
11
|
+
* `$XDG_CONFIG_HOME/skitterspec/credentials.json` (else `~/.config/…`), keyed by
|
|
12
|
+
* Linear team id so one file serves every checkout.
|
|
13
|
+
*
|
|
14
|
+
* Reads never throw. `resolveApiKey` already treats "no key" as a NORMAL state
|
|
15
|
+
* meaning "fall back to MCP" rather than an error, so every failure here returns
|
|
16
|
+
* a structured reason the caller can report or ignore — a missing store is not a
|
|
17
|
+
* problem, it is the default.
|
|
18
|
+
*
|
|
19
|
+
* Nothing in this module ever returns a key inside an error, and callers must
|
|
20
|
+
* keep it out of logs, plans, snapshots and stamped frontmatter.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const { spawnSync } = require('node:child_process')
|
|
24
|
+
const fs = require('node:fs')
|
|
25
|
+
const os = require('node:os')
|
|
26
|
+
const path = require('node:path')
|
|
27
|
+
|
|
28
|
+
const DIR_NAME = 'skitterspec'
|
|
29
|
+
const FILE_NAME = 'credentials.json'
|
|
30
|
+
|
|
31
|
+
// Modes wider than owner-only. Checked ssh-style: a store other users can read
|
|
32
|
+
// is refused rather than read, because silently using it would hide the leak,
|
|
33
|
+
// and silently chmod'ing someone's file is not ours to do.
|
|
34
|
+
const GROUP_OR_WORLD = 0o077
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Absolute path of the store. Honours `$XDG_CONFIG_HOME`, else `~/.config`.
|
|
38
|
+
* `env` and `homedir` are injected so tests never touch a real home directory.
|
|
39
|
+
*/
|
|
40
|
+
function storePath(env = process.env, homedir = os.homedir) {
|
|
41
|
+
const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.trim()
|
|
42
|
+
const base = xdg || path.join(homedir(), '.config')
|
|
43
|
+
return path.join(base, DIR_NAME, FILE_NAME)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read the store.
|
|
48
|
+
*
|
|
49
|
+
* @returns {object} `{ ok: true, store, path }` — parsed and owner-only;
|
|
50
|
+
* `{ ok: false, reason, code, path }` otherwise. `code` is one of:
|
|
51
|
+
* `absent` (no file — the normal default, not a problem), `permissions`
|
|
52
|
+
* (group/world readable), `unreadable`, `malformed`.
|
|
53
|
+
*/
|
|
54
|
+
function readStore(file, { stat = fs.statSync, read = fs.readFileSync } = {}) {
|
|
55
|
+
let info
|
|
56
|
+
try {
|
|
57
|
+
info = stat(file)
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err.code === 'ENOENT') {
|
|
60
|
+
return { ok: false, code: 'absent', path: file, reason: `no credentials store at ${file}` }
|
|
61
|
+
}
|
|
62
|
+
return { ok: false, code: 'unreadable', path: file, reason: `cannot read ${file}: ${err.message}` }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (info.mode & GROUP_OR_WORLD) {
|
|
66
|
+
const mode = (info.mode & 0o777).toString(8)
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
code: 'permissions',
|
|
70
|
+
path: file,
|
|
71
|
+
reason:
|
|
72
|
+
`credentials store ${file} is mode ${mode} — readable by other users.\n` +
|
|
73
|
+
` Run: chmod 600 ${file}`,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let raw
|
|
78
|
+
try {
|
|
79
|
+
raw = read(file, 'utf-8')
|
|
80
|
+
} catch (err) {
|
|
81
|
+
return { ok: false, code: 'unreadable', path: file, reason: `cannot read ${file}: ${err.message}` }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let store
|
|
85
|
+
try {
|
|
86
|
+
store = JSON.parse(raw)
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return { ok: false, code: 'malformed', path: file, reason: `invalid JSON in ${file}: ${err.message}` }
|
|
89
|
+
}
|
|
90
|
+
if (!store || typeof store !== 'object' || Array.isArray(store)) {
|
|
91
|
+
return { ok: false, code: 'malformed', path: file, reason: `invalid credentials store in ${file}` }
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, store, path: file }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The key recorded for one team, or null. Never throws, and never reports the
|
|
98
|
+
* value it did or didn't find.
|
|
99
|
+
*/
|
|
100
|
+
function keyForTeam(store, teamId) {
|
|
101
|
+
if (!teamId) return null
|
|
102
|
+
const teams = store && store.teams
|
|
103
|
+
const entry = teams && typeof teams === 'object' ? teams[teamId] : null
|
|
104
|
+
if (!entry || typeof entry !== 'object') return null
|
|
105
|
+
const key = entry.key
|
|
106
|
+
return typeof key === 'string' && key.trim() ? key.trim() : null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Last 4 characters, for reporting that a key exists without revealing it.
|
|
110
|
+
function fingerprint(key) {
|
|
111
|
+
if (typeof key !== 'string' || !key) return null
|
|
112
|
+
return `…${key.slice(-4)}`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Record a key for one team, creating the store at `0600` and its directory at
|
|
118
|
+
* `0700`. Other teams' entries are preserved.
|
|
119
|
+
*
|
|
120
|
+
* Refuses rather than writing when the existing store is unreadable or
|
|
121
|
+
* over-permissive — the same guard as reading, because silently rewriting a
|
|
122
|
+
* world-readable file would leave the leak in place.
|
|
123
|
+
*
|
|
124
|
+
* Returns `{ ok: true, path, created }` or `{ ok: false, reason }`. The key is
|
|
125
|
+
* never echoed back in either.
|
|
126
|
+
*/
|
|
127
|
+
function writeKey(file, teamId, key, deps = {}) {
|
|
128
|
+
const mkdir = deps.mkdir || fs.mkdirSync
|
|
129
|
+
const write = deps.write || fs.writeFileSync
|
|
130
|
+
const chmod = deps.chmod || fs.chmodSync
|
|
131
|
+
const exists = deps.exists || fs.existsSync
|
|
132
|
+
|
|
133
|
+
if (!teamId) return { ok: false, reason: 'no team id — nothing to key the entry by' }
|
|
134
|
+
if (typeof key !== 'string' || !key.trim()) return { ok: false, reason: 'empty key — nothing stored' }
|
|
135
|
+
|
|
136
|
+
const created = !exists(file)
|
|
137
|
+
let store = { version: 1, teams: {} }
|
|
138
|
+
if (!created) {
|
|
139
|
+
const current = readStore(file, deps)
|
|
140
|
+
if (!current.ok) return { ok: false, reason: current.reason, code: current.code }
|
|
141
|
+
store = current.store
|
|
142
|
+
if (!store.teams || typeof store.teams !== 'object') store.teams = {}
|
|
143
|
+
if (!store.version) store.version = 1
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
store.teams[teamId] = { ...(store.teams[teamId] || {}), key: key.trim() }
|
|
147
|
+
|
|
148
|
+
mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
149
|
+
write(file, JSON.stringify(store, null, 2) + '\n', { mode: 0o600 })
|
|
150
|
+
// `mode` on writeFileSync only applies when the file is CREATED, so an
|
|
151
|
+
// existing file keeps its mode — narrow it explicitly.
|
|
152
|
+
chmod(file, 0o600)
|
|
153
|
+
return { ok: true, path: file, created }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Remove one team's entry, leaving every other team intact. A store or entry
|
|
158
|
+
* that isn't there is a clean no-op, not an error.
|
|
159
|
+
*/
|
|
160
|
+
function removeKey(file, teamId, deps = {}) {
|
|
161
|
+
const write = deps.write || fs.writeFileSync
|
|
162
|
+
const current = readStore(file, deps)
|
|
163
|
+
if (!current.ok) {
|
|
164
|
+
if (current.code === 'absent') return { ok: true, path: file, removed: false }
|
|
165
|
+
return { ok: false, reason: current.reason, code: current.code }
|
|
166
|
+
}
|
|
167
|
+
const teams = current.store.teams
|
|
168
|
+
if (!teams || !teams[teamId]) return { ok: true, path: file, removed: false }
|
|
169
|
+
delete teams[teamId]
|
|
170
|
+
write(file, JSON.stringify(current.store, null, 2) + '\n', { mode: 0o600 })
|
|
171
|
+
return { ok: true, path: file, removed: true }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolve one team's key from the store: a stored `key` first, else running its
|
|
176
|
+
* `keyCommand` and taking stdout.
|
|
177
|
+
*
|
|
178
|
+
* `keyCommand` is honoured ONLY from this user-level store — never from the
|
|
179
|
+
* repo's committed `linear.config.json`. That file travels with the repo, so a
|
|
180
|
+
* command named there would let a cloned repo run arbitrary code on the machine
|
|
181
|
+
* of anyone who ran `spec-sync`. The repo config keeps naming only an env var.
|
|
182
|
+
*
|
|
183
|
+
* A command that fails, times out or prints nothing resolves to no key, which is
|
|
184
|
+
* the ordinary "fall back to MCP" state — but its stderr is carried back so a
|
|
185
|
+
* broken command is diagnosable rather than mysteriously inert. Its **stdout is
|
|
186
|
+
* never** put in an error: that is the key.
|
|
187
|
+
*
|
|
188
|
+
* @returns {object} `{ key, source }` with source `'store'` | `'command'`, or
|
|
189
|
+
* `{ key: null, source: null, reason }`.
|
|
190
|
+
*/
|
|
191
|
+
function resolveTeamKey(store, teamId, deps = {}) {
|
|
192
|
+
const direct = keyForTeam(store, teamId)
|
|
193
|
+
if (direct) return { key: direct, source: 'store' }
|
|
194
|
+
|
|
195
|
+
const entry = store && store.teams && typeof store.teams === 'object' ? store.teams[teamId] : null
|
|
196
|
+
const command = entry && typeof entry.keyCommand === 'string' ? entry.keyCommand.trim() : ''
|
|
197
|
+
if (!command) return { key: null, source: null }
|
|
198
|
+
|
|
199
|
+
const run = deps.run || defaultRunCommand
|
|
200
|
+
const result = run(command, deps.timeoutMs || COMMAND_TIMEOUT_MS)
|
|
201
|
+
if (!result.ok) {
|
|
202
|
+
return { key: null, source: null, command, reason: `keyCommand failed: ${result.error}` }
|
|
203
|
+
}
|
|
204
|
+
const key = (result.stdout || '').trim()
|
|
205
|
+
if (!key) {
|
|
206
|
+
return { key: null, source: null, command, reason: 'keyCommand produced no output' }
|
|
207
|
+
}
|
|
208
|
+
return { key, source: 'command', command }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 60s: a password manager may prompt for biometric or a master password, and a
|
|
212
|
+
// hang here would look like the CLI itself wedging.
|
|
213
|
+
const COMMAND_TIMEOUT_MS = 60_000
|
|
214
|
+
|
|
215
|
+
// Linear personal API keys are `lin_api_` + a long token.
|
|
216
|
+
const LITERAL_KEY_RE = /lin_api_[A-Za-z0-9]{8,}/
|
|
217
|
+
|
|
218
|
+
function defaultRunCommand(command, timeout) {
|
|
219
|
+
const r = spawnSync('sh', ['-c', command], { encoding: 'utf-8', timeout })
|
|
220
|
+
if (r.error) return { ok: false, error: r.error.message }
|
|
221
|
+
if (r.status !== 0) {
|
|
222
|
+
// stderr only — stdout is the secret, and must not reach an error message.
|
|
223
|
+
const detail = (r.stderr || '').trim().split('\n')[0] || `exit ${r.status}`
|
|
224
|
+
return { ok: false, error: detail }
|
|
225
|
+
}
|
|
226
|
+
return { ok: true, stdout: r.stdout }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Record a command for one team instead of a key. Unlike a key, a command is not
|
|
231
|
+
* a secret, so this one IS safe to pass as an argument.
|
|
232
|
+
*/
|
|
233
|
+
function writeKeyCommand(file, teamId, command, deps = {}) {
|
|
234
|
+
const mkdir = deps.mkdir || fs.mkdirSync
|
|
235
|
+
const write = deps.write || fs.writeFileSync
|
|
236
|
+
const chmod = deps.chmod || fs.chmodSync
|
|
237
|
+
const exists = deps.exists || fs.existsSync
|
|
238
|
+
|
|
239
|
+
if (!teamId) return { ok: false, reason: 'no team id — nothing to key the entry by' }
|
|
240
|
+
if (typeof command !== 'string' || !command.trim()) {
|
|
241
|
+
return { ok: false, reason: 'empty command — nothing stored' }
|
|
242
|
+
}
|
|
243
|
+
// Refusing `--key` makes `--command "echo lin_api_…"` the obvious workaround,
|
|
244
|
+
// and it is worse: a command is NOT treated as a secret — `status` prints it
|
|
245
|
+
// back, and it sits in the store in clear. Catch the shortcut at the door.
|
|
246
|
+
if (LITERAL_KEY_RE.test(command)) {
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
reason:
|
|
250
|
+
'that command has a Linear key written into it.\n' +
|
|
251
|
+
' A command is displayed by `credentials status` and is not treated as a\n' +
|
|
252
|
+
' secret — embedding a key there exposes it. To store a key, run\n' +
|
|
253
|
+
' `credentials set` with no arguments and paste at the hidden prompt.',
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const created = !exists(file)
|
|
258
|
+
let store = { version: 1, teams: {} }
|
|
259
|
+
if (!created) {
|
|
260
|
+
const current = readStore(file, deps)
|
|
261
|
+
if (!current.ok) return { ok: false, reason: current.reason, code: current.code }
|
|
262
|
+
store = current.store
|
|
263
|
+
if (!store.teams || typeof store.teams !== 'object') store.teams = {}
|
|
264
|
+
if (!store.version) store.version = 1
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// A command REPLACES a stored key for that team — keeping both would mean the
|
|
268
|
+
// key silently wins and the command never runs.
|
|
269
|
+
store.teams[teamId] = { keyCommand: command.trim() }
|
|
270
|
+
|
|
271
|
+
mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
272
|
+
write(file, JSON.stringify(store, null, 2) + '\n', { mode: 0o600 })
|
|
273
|
+
chmod(file, 0o600)
|
|
274
|
+
return { ok: true, path: file, created }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** The store's permission bits as an octal string, or null when absent. */
|
|
278
|
+
function storeMode(file, deps = {}) {
|
|
279
|
+
const stat = deps.stat || fs.statSync
|
|
280
|
+
try {
|
|
281
|
+
return (stat(file).mode & 0o777).toString(8)
|
|
282
|
+
} catch {
|
|
283
|
+
return null
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
module.exports = {
|
|
288
|
+
storePath,
|
|
289
|
+
readStore,
|
|
290
|
+
keyForTeam,
|
|
291
|
+
fingerprint,
|
|
292
|
+
writeKey,
|
|
293
|
+
writeKeyCommand,
|
|
294
|
+
resolveTeamKey,
|
|
295
|
+
removeKey,
|
|
296
|
+
storeMode,
|
|
297
|
+
DIR_NAME,
|
|
298
|
+
FILE_NAME,
|
|
299
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Identifier drift — the offline half of `spec-sync doctor`.
|
|
5
|
+
*
|
|
6
|
+
* When a Linear team is renamed (`REU` → `ERQ`), nothing in the repo moves: every
|
|
7
|
+
* stamped `linear_identifier` / `linear_issue_id` / `linear_url`, the config's
|
|
8
|
+
* `teamKey`, and every `linear-base/<ID>.base.json` filename keeps the old
|
|
9
|
+
* prefix. Nothing detected it and nothing repaired it; the first occurrence was
|
|
10
|
+
* fixed by hand across 221 refs in 54 files.
|
|
11
|
+
*
|
|
12
|
+
* This module only SCANS — it reads the repo and reports what disagrees with a
|
|
13
|
+
* team key it is handed. It performs no network calls and writes nothing, so the
|
|
14
|
+
* detection logic is testable without an adapter. Deciding what the current key
|
|
15
|
+
* IS (a Linear read) and repairing (phase 3) live in `cli-sync.js`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('node:fs')
|
|
19
|
+
const path = require('node:path')
|
|
20
|
+
const { execFileSync } = require('node:child_process')
|
|
21
|
+
|
|
22
|
+
const { BUCKETS } = require('../../env/resolve.js')
|
|
23
|
+
const { parseFrontmatter } = require('../sync-core')
|
|
24
|
+
|
|
25
|
+
// A Linear issue identifier: an uppercase team key, a dash, a number. The key is
|
|
26
|
+
// captured so drift is a prefix comparison rather than a guess.
|
|
27
|
+
const IDENTIFIER_RE = /^([A-Z][A-Z0-9]*)-(\d+)$/
|
|
28
|
+
// The same, embedded in a URL path (`…/issue/REU-151/slug`).
|
|
29
|
+
const URL_IDENTIFIER_RE = /\b([A-Z][A-Z0-9]*)-(\d+)\b/g
|
|
30
|
+
|
|
31
|
+
const STAMP_FIELDS = ['linear_identifier', 'linear_issue_id']
|
|
32
|
+
|
|
33
|
+
// Every `.md` under each spec folder — overview and phase files alike, plus the
|
|
34
|
+
// legacy bare `<name>.md` shape. Anything stamped lives in one of these.
|
|
35
|
+
function specMarkdownFiles(dir) {
|
|
36
|
+
const files = []
|
|
37
|
+
for (const bucket of BUCKETS) {
|
|
38
|
+
const root = path.join(dir, 'specs', bucket)
|
|
39
|
+
let entries
|
|
40
|
+
try {
|
|
41
|
+
entries = fs.readdirSync(root, { withFileTypes: true })
|
|
42
|
+
} catch {
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
const p = path.join(root, entry.name)
|
|
47
|
+
if (entry.isDirectory()) {
|
|
48
|
+
for (const f of fs.readdirSync(p)) if (f.endsWith('.md')) files.push(path.join(p, f))
|
|
49
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
50
|
+
files.push(p)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return files.sort()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Retarget one identifier onto `currentKey`, preserving the number. Returns null
|
|
58
|
+
// when it is already current or is not an identifier at all.
|
|
59
|
+
function retarget(value, currentKey) {
|
|
60
|
+
const m = IDENTIFIER_RE.exec(String(value).trim())
|
|
61
|
+
if (!m || m[1] === currentKey) return null
|
|
62
|
+
return `${currentKey}-${m[2]}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Scan the repo for identifiers that disagree with `currentKey`.
|
|
67
|
+
*
|
|
68
|
+
* @returns {{
|
|
69
|
+
* stamps: Array<{file, field, from, to}>,
|
|
70
|
+
* urls: Array<{file, from, to}>,
|
|
71
|
+
* snapshots: Array<{from, to}>,
|
|
72
|
+
* snapshotKeys: Array<{file, from, to}>,
|
|
73
|
+
* mentions: Array<{file, from, to}>, // prose refs — reported, never repaired
|
|
74
|
+
* config: {from, to}|null,
|
|
75
|
+
* refs: Array<{from, to}>,
|
|
76
|
+
* }} `refs` is the DISTINCT set of drifted identifiers — what the caller checks
|
|
77
|
+
* against Linear, so 221 stamps of 198 identifiers cost 198 reads, not 221.
|
|
78
|
+
*/
|
|
79
|
+
function scanDrift(dir, config, currentKey) {
|
|
80
|
+
const stamps = []
|
|
81
|
+
const urls = []
|
|
82
|
+
const seen = new Map()
|
|
83
|
+
const note = (from, to) => seen.set(from, to)
|
|
84
|
+
|
|
85
|
+
for (const file of specMarkdownFiles(dir)) {
|
|
86
|
+
let raw
|
|
87
|
+
try {
|
|
88
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
89
|
+
} catch {
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
const { data } = parseFrontmatter(raw)
|
|
93
|
+
for (const field of STAMP_FIELDS) {
|
|
94
|
+
if (!data[field]) continue
|
|
95
|
+
const to = retarget(data[field], currentKey)
|
|
96
|
+
if (!to) continue
|
|
97
|
+
stamps.push({ file: path.relative(dir, file), field, from: String(data[field]).trim(), to })
|
|
98
|
+
note(String(data[field]).trim(), to)
|
|
99
|
+
}
|
|
100
|
+
if (data.linear_url) {
|
|
101
|
+
const url = String(data.linear_url)
|
|
102
|
+
let changed = url
|
|
103
|
+
for (const m of url.matchAll(URL_IDENTIFIER_RE)) {
|
|
104
|
+
const to = retarget(m[0], currentKey)
|
|
105
|
+
if (!to) continue
|
|
106
|
+
changed = changed.split(m[0]).join(to)
|
|
107
|
+
note(m[0], to)
|
|
108
|
+
}
|
|
109
|
+
if (changed !== url) urls.push({ file: path.relative(dir, file), from: url, to: changed })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const snapshots = []
|
|
114
|
+
const snapshotKeys = []
|
|
115
|
+
const baseDir = path.resolve(dir, config.sync.baseDir)
|
|
116
|
+
let snapshotNames = []
|
|
117
|
+
try {
|
|
118
|
+
snapshotNames = fs.readdirSync(baseDir).filter((f) => f.endsWith('.base.json'))
|
|
119
|
+
} catch {
|
|
120
|
+
/* no snapshots yet — nothing to retarget */
|
|
121
|
+
}
|
|
122
|
+
for (const name of snapshotNames.sort()) {
|
|
123
|
+
const to = retarget(name.slice(0, -'.base.json'.length), currentKey)
|
|
124
|
+
if (to) {
|
|
125
|
+
snapshots.push({ from: name, to: `${to}.base.json` })
|
|
126
|
+
note(name.slice(0, -'.base.json'.length), to)
|
|
127
|
+
}
|
|
128
|
+
// A snapshot's `subIssues` map is KEYED BY IDENTIFIER, so a rename strands
|
|
129
|
+
// every key inside the file as well as the filename. Missing these made the
|
|
130
|
+
// first scan report 59 refs where the repo really carries ~198: the bulk of
|
|
131
|
+
// a linked repo's identifiers live in here, not in frontmatter. The hashes
|
|
132
|
+
// are content-derived and stay valid — only their keys move.
|
|
133
|
+
let body
|
|
134
|
+
try {
|
|
135
|
+
body = JSON.parse(fs.readFileSync(path.join(baseDir, name), 'utf-8'))
|
|
136
|
+
} catch {
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
for (const ident of Object.keys((body && body.subIssues) || {})) {
|
|
140
|
+
const keyTo = retarget(ident, currentKey)
|
|
141
|
+
if (!keyTo) continue
|
|
142
|
+
snapshotKeys.push({ file: path.join(config.sync.baseDir, name), from: ident, to: keyTo })
|
|
143
|
+
note(ident, keyTo)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Prose MENTIONS of a stale identifier — `(REU-61)` beside a task, "the REU-196
|
|
148
|
+
// spec claimed …". These are human-written references, not functional stamps,
|
|
149
|
+
// and repair deliberately leaves them alone: rewriting narrative text is a
|
|
150
|
+
// different risk class, and an identifier-shaped token in prose need not be a
|
|
151
|
+
// Linear ref at all. They are counted anyway so the report cannot imply a
|
|
152
|
+
// `--write` left the repo fully retargeted when ~145 mentions still say REU.
|
|
153
|
+
//
|
|
154
|
+
// Only prefixes that actually appear in the repo's STAMPS are counted, so an
|
|
155
|
+
// unrelated `ABC-123` in prose is never mistaken for a drifted ref.
|
|
156
|
+
const staleKeys = new Set([...seen.keys()].map((k) => k.split('-')[0]))
|
|
157
|
+
const mentions = []
|
|
158
|
+
if (staleKeys.size) {
|
|
159
|
+
for (const file of specMarkdownFiles(dir)) {
|
|
160
|
+
let raw
|
|
161
|
+
try {
|
|
162
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
163
|
+
} catch {
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
const { body } = parseFrontmatter(raw)
|
|
167
|
+
for (const m of String(body).matchAll(URL_IDENTIFIER_RE)) {
|
|
168
|
+
if (!staleKeys.has(m[1]) || m[1] === currentKey) continue
|
|
169
|
+
mentions.push({ file: path.relative(dir, file), from: m[0], to: `${currentKey}-${m[2]}` })
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const configured = (config.linear && config.linear.teamKey) || ''
|
|
175
|
+
const configDrift = configured && configured !== currentKey ? { from: configured, to: currentKey } : null
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
stamps,
|
|
179
|
+
urls,
|
|
180
|
+
snapshots,
|
|
181
|
+
snapshotKeys,
|
|
182
|
+
mentions,
|
|
183
|
+
config: configDrift,
|
|
184
|
+
refs: [...seen.entries()].map(([from, to]) => ({ from, to })).sort((a, b) => a.from.localeCompare(b.from)),
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// True when a scan found nothing to repair.
|
|
189
|
+
function isClean(drift) {
|
|
190
|
+
return (
|
|
191
|
+
!drift.stamps.length &&
|
|
192
|
+
!drift.urls.length &&
|
|
193
|
+
!drift.snapshots.length &&
|
|
194
|
+
!drift.snapshotKeys.length &&
|
|
195
|
+
!drift.config
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// How many distinct files a repair would touch.
|
|
200
|
+
function fileCount(drift) {
|
|
201
|
+
return new Set([...drift.stamps.map((s) => s.file), ...drift.urls.map((u) => u.file)]).size
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// --- repair ------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
// `git status --porcelain` over `dir`: [] when clean, the offending lines when
|
|
207
|
+
// dirty, null when this is not a git repo at all.
|
|
208
|
+
function dirtyPaths(dir) {
|
|
209
|
+
let out
|
|
210
|
+
try {
|
|
211
|
+
out = execFileSync('git', ['-C', dir, 'status', '--porcelain'], {
|
|
212
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
213
|
+
})
|
|
214
|
+
.toString()
|
|
215
|
+
.trim()
|
|
216
|
+
} catch {
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
return out ? out.split('\n') : []
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Rewrite identifier tokens INSIDE a file's frontmatter block only.
|
|
223
|
+
//
|
|
224
|
+
// Scoped to the frontmatter on purpose: the same token appears in spec prose,
|
|
225
|
+
// which repair deliberately leaves alone. A blind whole-file replace would
|
|
226
|
+
// rewrite narrative text as a side effect of fixing a stamp.
|
|
227
|
+
function rewriteFrontmatter(raw, replacements) {
|
|
228
|
+
const m = /^(---\n[\s\S]*?\n---)(\n[\s\S]*)?$/.exec(raw)
|
|
229
|
+
if (!m) return raw
|
|
230
|
+
let head = m[1]
|
|
231
|
+
for (const [from, to] of replacements) head = head.split(from).join(to)
|
|
232
|
+
return head + (m[2] || '')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Move a file, preferring `git mv` so history survives. Falls back to a plain
|
|
236
|
+
// rename when the file is untracked (git mv refuses those) or git is absent.
|
|
237
|
+
function moveFile(dir, from, to) {
|
|
238
|
+
try {
|
|
239
|
+
execFileSync('git', ['-C', dir, 'mv', from, to], { stdio: ['ignore', 'ignore', 'ignore'] })
|
|
240
|
+
return 'git mv'
|
|
241
|
+
} catch {
|
|
242
|
+
fs.renameSync(path.join(dir, from), path.join(dir, to))
|
|
243
|
+
return 'rename'
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Apply a scan's repairs. Everything moves together — config, stamps, snapshot
|
|
249
|
+
* filenames and the identifier keys inside them — because a half-repaired repo
|
|
250
|
+
* is harder to reason about than an un-repaired one.
|
|
251
|
+
*
|
|
252
|
+
* `skip` is the set of `from` identifiers that resolve to NO issue under the new
|
|
253
|
+
* key. Those are left exactly as they are: repair fixes what is provably
|
|
254
|
+
* repairable, and reports the rest rather than inventing a target.
|
|
255
|
+
*
|
|
256
|
+
* Prose mentions are never touched — see `scanDrift`.
|
|
257
|
+
*/
|
|
258
|
+
function repairDrift(dir, config, drift, { skip = new Set() } = {}) {
|
|
259
|
+
const keep = (r) => !skip.has(r.from)
|
|
260
|
+
const changed = { files: [], snapshots: [], config: false, skipped: 0 }
|
|
261
|
+
|
|
262
|
+
// 1. Frontmatter stamps and urls, one pass per file.
|
|
263
|
+
const byFile = new Map()
|
|
264
|
+
for (const r of [...drift.stamps, ...drift.urls]) {
|
|
265
|
+
if (!keep(r)) {
|
|
266
|
+
changed.skipped++
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
if (!byFile.has(r.file)) byFile.set(r.file, [])
|
|
270
|
+
byFile.get(r.file).push([r.from, r.to])
|
|
271
|
+
}
|
|
272
|
+
for (const [rel, replacements] of byFile) {
|
|
273
|
+
const abs = path.join(dir, rel)
|
|
274
|
+
const raw = fs.readFileSync(abs, 'utf-8')
|
|
275
|
+
const next = rewriteFrontmatter(raw, replacements)
|
|
276
|
+
if (next !== raw) {
|
|
277
|
+
fs.writeFileSync(abs, next, 'utf-8')
|
|
278
|
+
changed.files.push(rel)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 2. Snapshot sub-issue keys, rewritten BEFORE the filename moves so the path
|
|
283
|
+
// being read is still the one the scan recorded.
|
|
284
|
+
const keysByFile = new Map()
|
|
285
|
+
for (const k of drift.snapshotKeys) {
|
|
286
|
+
if (!keep(k)) {
|
|
287
|
+
changed.skipped++
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
if (!keysByFile.has(k.file)) keysByFile.set(k.file, [])
|
|
291
|
+
keysByFile.get(k.file).push(k)
|
|
292
|
+
}
|
|
293
|
+
for (const [rel, keys] of keysByFile) {
|
|
294
|
+
const abs = path.join(dir, rel)
|
|
295
|
+
const body = JSON.parse(fs.readFileSync(abs, 'utf-8'))
|
|
296
|
+
const subIssues = {}
|
|
297
|
+
// The hashes are CONTENT-derived, so they survive a rename untouched — only
|
|
298
|
+
// the keys move. Rebuilt rather than mutated so key order stays stable.
|
|
299
|
+
for (const [ident, hash] of Object.entries(body.subIssues || {})) {
|
|
300
|
+
const hit = keys.find((k) => k.from === ident)
|
|
301
|
+
subIssues[hit ? hit.to : ident] = hash
|
|
302
|
+
}
|
|
303
|
+
fs.writeFileSync(abs, JSON.stringify({ ...body, subIssues }, null, 2) + '\n', 'utf-8')
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// 3. Snapshot filenames.
|
|
307
|
+
const baseRel = config.sync.baseDir
|
|
308
|
+
for (const snap of drift.snapshots) {
|
|
309
|
+
const ident = snap.from.slice(0, -'.base.json'.length)
|
|
310
|
+
if (skip.has(ident)) {
|
|
311
|
+
changed.skipped++
|
|
312
|
+
continue
|
|
313
|
+
}
|
|
314
|
+
const how = moveFile(dir, path.join(baseRel, snap.from), path.join(baseRel, snap.to))
|
|
315
|
+
changed.snapshots.push({ ...snap, how })
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 4. The config key, last: it is the thing that makes the next scan read
|
|
319
|
+
// clean, so it should not flip before the files it describes have moved.
|
|
320
|
+
if (drift.config) {
|
|
321
|
+
const file = path.join(dir, 'specs', '.core', 'linear.config.json')
|
|
322
|
+
const raw = fs.readFileSync(file, 'utf-8')
|
|
323
|
+
// Textual, not parse-and-restringify: the config is hand-edited and carries
|
|
324
|
+
// comments and ordering a JSON round-trip would silently discard.
|
|
325
|
+
fs.writeFileSync(file, raw.replace(/("teamKey"\s*:\s*")([^"]*)(")/, `$1${drift.config.to}$3`), 'utf-8')
|
|
326
|
+
changed.config = true
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return changed
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
module.exports = {
|
|
333
|
+
scanDrift,
|
|
334
|
+
isClean,
|
|
335
|
+
fileCount,
|
|
336
|
+
retarget,
|
|
337
|
+
specMarkdownFiles,
|
|
338
|
+
dirtyPaths,
|
|
339
|
+
repairDrift,
|
|
340
|
+
rewriteFrontmatter,
|
|
341
|
+
}
|
|
@@ -71,7 +71,9 @@ function snapshotOf(projection) {
|
|
|
71
71
|
/**
|
|
72
72
|
* Diff the local projection against the last-pushed snapshot.
|
|
73
73
|
* @returns {{ issue?: object, subIssues: {create,update} }}
|
|
74
|
-
* create items carry a `ref` (local handle) and no id; update items carry
|
|
74
|
+
* create items carry a `ref` (local handle) and no id; update items carry both
|
|
75
|
+
* — the `ref` because the read-back check matches sub-issues to phases BY ref,
|
|
76
|
+
* and an update with only an id makes every one of them look unmatched.
|
|
75
77
|
* `plan.issue` (when present) is the spec issue's description + state; the push
|
|
76
78
|
* skill applies `config.linear.projectId` grouping on top of it.
|
|
77
79
|
*/
|
|
@@ -85,7 +87,7 @@ function planChanges(projection, snapshot) {
|
|
|
85
87
|
if (s.id == null) {
|
|
86
88
|
subIssues.create.push({ ref: s.ref, name: s.name, goal: s.goal, state: s.state })
|
|
87
89
|
} else if (snapS[String(s.id)] !== subIssueHash(s)) {
|
|
88
|
-
subIssues.update.push({ id: s.id, name: s.name, goal: s.goal, state: s.state })
|
|
90
|
+
subIssues.update.push({ ref: s.ref, id: s.id, name: s.name, goal: s.goal, state: s.state })
|
|
89
91
|
}
|
|
90
92
|
}
|
|
91
93
|
|