@skitterbyte/skitterspec 1.0.0 → 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/README.md +27 -244
- package/assets/claude-md-section.md +0 -6
- package/assets/core/env.config.json.example +5 -1
- package/assets/core/env.config.md +21 -5
- package/assets/rules/spec-planning.md +14 -10
- package/assets/skills/spec/SKILL.md +11 -38
- package/assets/skills/spec-complete/SKILL.md +31 -4
- package/assets/skills/spec-env/SKILL.md +6 -0
- package/assets/skills/spec-env-down/SKILL.md +16 -8
- package/assets/skills/spec-go/SKILL.md +15 -17
- package/package.json +6 -11
- package/src/cli.js +174 -318
- package/src/deprecate.js +138 -0
- package/src/env/config.js +17 -4
- package/src/env/integrate.js +46 -0
- package/src/env/resolve.js +54 -45
- package/src/env/teardown.js +19 -4
- package/src/env/trust.js +87 -0
- package/src/init.js +78 -170
- package/src/prompts.js +26 -63
- package/LICENSE +0 -21
- package/assets/core/linear.config.json.example +0 -39
- package/assets/core/linear.config.md +0 -121
- package/assets/rules/commit-messages.md +0 -85
- package/assets/scripts/generate-changelog.js +0 -274
- package/assets/scripts/generate-releases.js +0 -360
- package/assets/scripts/lib/config.js +0 -127
- package/assets/scripts/lib/git-commits.js +0 -265
- package/assets/skills/commit/SKILL.md +0 -28
- package/assets/skills/spec-pull/SKILL.md +0 -46
- package/assets/skills/spec-push/SKILL.md +0 -53
- package/assets/skills/spec-status/SKILL.md +0 -46
- package/src/config.js +0 -13
- package/src/sync/apply.js +0 -66
- package/src/sync/base.js +0 -83
- package/src/sync/compare.js +0 -99
- package/src/sync/config.js +0 -198
- package/src/sync/mcp.js +0 -112
- package/src/sync/normalize.js +0 -249
- package/src/sync/pull.js +0 -84
- package/src/sync/push.js +0 -106
- package/src/sync/write.js +0 -86
package/src/sync/normalize.js
DELETED
|
@@ -1,249 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Normalize a Linear Project projection and a local spec snapshot into the SAME
|
|
5
|
-
* field set, so the three-way compare (compare.js) can diff them field by field.
|
|
6
|
-
*
|
|
7
|
-
* Both `normalizeLocal(snapshotDir, config)` and `normalizeRemote(project, config)`
|
|
8
|
-
* return an object whose keys are exactly `config.sync.fieldOwnership`'s keys —
|
|
9
|
-
* identical field sets by construction. A field a given side can't supply is
|
|
10
|
-
* `null` (scalars) or `[]` (collections), never absent, so the sets stay equal.
|
|
11
|
-
*
|
|
12
|
-
* Pure: `normalizeLocal` reads files under `snapshotDir` but makes no other side
|
|
13
|
-
* effects and no Date.now()/Math.random(). `localOnlySections` are stripped from
|
|
14
|
-
* the local `description` so they're never pushed to Linear.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
const fs = require('node:fs')
|
|
18
|
-
const path = require('node:path')
|
|
19
|
-
|
|
20
|
-
// --- markdown / frontmatter parsing -----------------------------------------
|
|
21
|
-
|
|
22
|
-
// Split `---\n…\n---` frontmatter off the top. Returns { data, body }.
|
|
23
|
-
function parseFrontmatter(raw) {
|
|
24
|
-
const m = /^---\n([\s\S]*?)\n---\n?/.exec(raw)
|
|
25
|
-
if (!m) return { data: {}, body: raw }
|
|
26
|
-
const data = {}
|
|
27
|
-
for (const line of m[1].split('\n')) {
|
|
28
|
-
const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line)
|
|
29
|
-
if (!kv) continue
|
|
30
|
-
data[kv[1]] = parseScalar(kv[2].trim())
|
|
31
|
-
}
|
|
32
|
-
return { data, body: raw.slice(m[0].length) }
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Parse a frontmatter scalar: quoted string, JSON array, number, or bare string.
|
|
36
|
-
function parseScalar(v) {
|
|
37
|
-
if (v === '') return null
|
|
38
|
-
const unq = /^["'](.*)["']$/.exec(v)
|
|
39
|
-
if (unq) return unq[1]
|
|
40
|
-
if (v.startsWith('[')) {
|
|
41
|
-
try {
|
|
42
|
-
return JSON.parse(v)
|
|
43
|
-
} catch {
|
|
44
|
-
return v
|
|
45
|
-
.replace(/^\[|\]$/g, '')
|
|
46
|
-
.split(',')
|
|
47
|
-
.map((s) => s.trim().replace(/^["']|["']$/g, ''))
|
|
48
|
-
.filter(Boolean)
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v)
|
|
52
|
-
return v
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Split a markdown body into { title, sections } where sections maps a `## `
|
|
56
|
-
// heading text → its content (until the next `## `). The H1 `# ` is the title.
|
|
57
|
-
function parseSections(body) {
|
|
58
|
-
const lines = body.split('\n')
|
|
59
|
-
let title = null
|
|
60
|
-
const sections = {}
|
|
61
|
-
let current = null
|
|
62
|
-
let buf = []
|
|
63
|
-
const flush = () => {
|
|
64
|
-
if (current !== null) sections[current] = buf.join('\n').trim()
|
|
65
|
-
}
|
|
66
|
-
for (const line of lines) {
|
|
67
|
-
const h1 = /^#\s+(.*)$/.exec(line)
|
|
68
|
-
const h2 = /^##\s+(.*)$/.exec(line)
|
|
69
|
-
if (h1 && title === null) {
|
|
70
|
-
title = h1[1].trim()
|
|
71
|
-
continue
|
|
72
|
-
}
|
|
73
|
-
if (h2) {
|
|
74
|
-
flush()
|
|
75
|
-
current = h2[1].trim()
|
|
76
|
-
buf = []
|
|
77
|
-
continue
|
|
78
|
-
}
|
|
79
|
-
if (current !== null) buf.push(line)
|
|
80
|
-
}
|
|
81
|
-
flush()
|
|
82
|
-
return { title, sections }
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Canonical milestone status from the phase-index emoji.
|
|
86
|
-
const EMOJI_STATUS = { '⬜': 'not-started', '🔄': 'in-progress', '✅': 'done' }
|
|
87
|
-
|
|
88
|
-
// Parse the "## Phases" index table into [{ name, status }] rows.
|
|
89
|
-
function parsePhaseIndex(phasesSection) {
|
|
90
|
-
if (!phasesSection) return []
|
|
91
|
-
const rows = []
|
|
92
|
-
for (const line of phasesSection.split('\n')) {
|
|
93
|
-
// | 1 | Phase name | ✅ | [01-…](01-…) |
|
|
94
|
-
const cells = line.split('|').map((c) => c.trim())
|
|
95
|
-
if (cells.length < 5) continue
|
|
96
|
-
const n = cells[1]
|
|
97
|
-
if (!/^\d+$/.test(n)) continue // skip header + separator rows
|
|
98
|
-
const name = cells[2]
|
|
99
|
-
const emoji = (cells[3].match(/[⬜🔄✅]/u) || [])[0]
|
|
100
|
-
rows.push({ name, status: EMOJI_STATUS[emoji] || 'not-started' })
|
|
101
|
-
}
|
|
102
|
-
return rows
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Read the phase files (01-*.md, 02-*.md …) in execution order.
|
|
106
|
-
function readPhaseFiles(snapshotDir) {
|
|
107
|
-
let entries
|
|
108
|
-
try {
|
|
109
|
-
entries = fs.readdirSync(snapshotDir)
|
|
110
|
-
} catch {
|
|
111
|
-
return []
|
|
112
|
-
}
|
|
113
|
-
return entries
|
|
114
|
-
.filter((f) => /^\d\d-.*\.md$/.test(f) && !f.startsWith('00-'))
|
|
115
|
-
.sort()
|
|
116
|
-
.map((file) => {
|
|
117
|
-
const raw = fs.readFileSync(path.join(snapshotDir, file), 'utf-8')
|
|
118
|
-
const goal = (/^\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/m.exec(raw) || [])[1] || ''
|
|
119
|
-
const tasks = (raw.match(/^-\s*\[[ x]\]\s*.*$/gm) || []).map((t) =>
|
|
120
|
-
t.replace(/^-\s*/, '').trim(),
|
|
121
|
-
)
|
|
122
|
-
return { phase: file.replace(/\.md$/, ''), goal: goal.trim(), tasks }
|
|
123
|
-
})
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// --- ownership-driven field set ---------------------------------------------
|
|
127
|
-
|
|
128
|
-
// Reduce an `extracted` map to exactly the configured field keys, defaulting a
|
|
129
|
-
// missing field to `null` so local and remote always share an identical set.
|
|
130
|
-
function toFieldSet(extracted, config) {
|
|
131
|
-
const out = {}
|
|
132
|
-
for (const field of Object.keys(config.sync.fieldOwnership)) {
|
|
133
|
-
out[field] = field in extracted ? extracted[field] : null
|
|
134
|
-
}
|
|
135
|
-
return out
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// --- local snapshot ---------------------------------------------------------
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Read a spec snapshot (its 00-overview.md + phase files) into the raw pieces the
|
|
142
|
-
* extractors and callers need. Pure aside from reads under `snapshotDir`.
|
|
143
|
-
*/
|
|
144
|
-
function readSnapshot(snapshotDir, config) {
|
|
145
|
-
const overviewFile = (config && config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
|
|
146
|
-
const raw = fs.readFileSync(path.join(snapshotDir, overviewFile), 'utf-8')
|
|
147
|
-
const { data, body } = parseFrontmatter(raw)
|
|
148
|
-
const { title, sections } = parseSections(body)
|
|
149
|
-
const phases = readPhaseFiles(snapshotDir)
|
|
150
|
-
return { frontmatter: data, title, sections, phases, body }
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Build the pushed description: the overview prose with local-only sections
|
|
154
|
-
// removed. Keeps the title line for context.
|
|
155
|
-
function buildDescription(title, sections, localOnlySections) {
|
|
156
|
-
const skip = new Set(localOnlySections || [])
|
|
157
|
-
const parts = []
|
|
158
|
-
if (title) parts.push(`# ${title}`)
|
|
159
|
-
for (const [heading, content] of Object.entries(sections)) {
|
|
160
|
-
if (skip.has(heading)) continue
|
|
161
|
-
parts.push(`## ${heading}\n\n${content}`.trim())
|
|
162
|
-
}
|
|
163
|
-
return parts.join('\n\n').trim() || null
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Normalize a local spec snapshot into the configured field set.
|
|
168
|
-
*/
|
|
169
|
-
function normalizeLocal(snapshotDir, config) {
|
|
170
|
-
const { frontmatter, title, sections, phases } = readSnapshot(snapshotDir, config)
|
|
171
|
-
const extracted = {
|
|
172
|
-
description: buildDescription(title, sections, config.sync.localOnlySections),
|
|
173
|
-
milestones: parsePhaseIndex(sections.Phases),
|
|
174
|
-
phaseBodies: phases.map((p) => ({ phase: p.phase, goal: p.goal })),
|
|
175
|
-
acceptanceCriteria: sections['Acceptance criteria'] || null,
|
|
176
|
-
taskBreakdown: phases.map((p) => ({ phase: p.phase, tasks: p.tasks })),
|
|
177
|
-
workflowState: frontmatter.spec_status != null ? String(frontmatter.spec_status) : null,
|
|
178
|
-
priority: frontmatter.priority != null ? frontmatter.priority : null,
|
|
179
|
-
labels: Array.isArray(frontmatter.labels) ? frontmatter.labels : [],
|
|
180
|
-
}
|
|
181
|
-
return toFieldSet(extracted, config)
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// --- remote projection ------------------------------------------------------
|
|
185
|
-
|
|
186
|
-
// Map a Linear workflow-state name back to the local lifecycle bucket (the
|
|
187
|
-
// vocabulary `spec_status` uses) via config.states, so local and remote
|
|
188
|
-
// workflowState hash equal when semantically equal. Falls back to a lowercased
|
|
189
|
-
// raw value when the state isn't one of the configured names.
|
|
190
|
-
function bucketForState(state, config) {
|
|
191
|
-
if (state == null) return null
|
|
192
|
-
const states = (config && config.states) || {}
|
|
193
|
-
const want = String(state).toLowerCase().trim()
|
|
194
|
-
for (const [bucket, name] of Object.entries(states)) {
|
|
195
|
-
if (typeof name === 'string' && name.toLowerCase().trim() === want) return bucket
|
|
196
|
-
}
|
|
197
|
-
return want
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// Canonicalise a Linear workflow-state name into the same vocabulary the local
|
|
201
|
-
// milestone emojis use, so equal states hash equal.
|
|
202
|
-
function canonicalRemoteStatus(state) {
|
|
203
|
-
const s = String(state || '').toLowerCase().trim()
|
|
204
|
-
if (!s) return 'not-started'
|
|
205
|
-
if (/(done|complete|completed|merged)/.test(s)) return 'done'
|
|
206
|
-
if (/(progress|started|doing|review)/.test(s)) return 'in-progress'
|
|
207
|
-
if (/(backlog|todo|planned|triage)/.test(s)) return 'not-started'
|
|
208
|
-
return s
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Normalize a Linear Project projection (from the Phase 2 MCP adapter, or a
|
|
213
|
-
* fixture) into the same field set as `normalizeLocal`.
|
|
214
|
-
*/
|
|
215
|
-
function normalizeRemote(project, config) {
|
|
216
|
-
const p = project || {}
|
|
217
|
-
const milestones = Array.isArray(p.milestones) ? p.milestones : []
|
|
218
|
-
const extracted = {
|
|
219
|
-
description: p.description != null ? p.description : null,
|
|
220
|
-
milestones: milestones.map((m) => ({
|
|
221
|
-
name: m.name,
|
|
222
|
-
status: canonicalRemoteStatus(m.status != null ? m.status : m.state),
|
|
223
|
-
})),
|
|
224
|
-
phaseBodies: milestones.map((m) => ({
|
|
225
|
-
phase: m.name,
|
|
226
|
-
goal: (m.description != null ? m.description : '').trim(),
|
|
227
|
-
})),
|
|
228
|
-
acceptanceCriteria: p.acceptanceCriteria != null ? p.acceptanceCriteria : null,
|
|
229
|
-
taskBreakdown: milestones.map((m) => ({
|
|
230
|
-
phase: m.name,
|
|
231
|
-
tasks: Array.isArray(m.tasks) ? m.tasks : [],
|
|
232
|
-
})),
|
|
233
|
-
workflowState: p.state != null ? bucketForState(p.state, config) : null,
|
|
234
|
-
priority: p.priority != null ? p.priority : null,
|
|
235
|
-
labels: Array.isArray(p.labels) ? p.labels : [],
|
|
236
|
-
}
|
|
237
|
-
return toFieldSet(extracted, config)
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
module.exports = {
|
|
241
|
-
normalizeLocal,
|
|
242
|
-
normalizeRemote,
|
|
243
|
-
readSnapshot,
|
|
244
|
-
parseFrontmatter,
|
|
245
|
-
parseSections,
|
|
246
|
-
parsePhaseIndex,
|
|
247
|
-
canonicalRemoteStatus,
|
|
248
|
-
bucketForState,
|
|
249
|
-
}
|
package/src/sync/pull.js
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* `pull` — Linear → repo, three-way aware.
|
|
5
|
-
*
|
|
6
|
-
* Applies remote-only fields to the local snapshot; a `both`-owned field where
|
|
7
|
-
* both sides moved off base is a real **conflict** and pull refuses (unless
|
|
8
|
-
* `--force`, which makes remote win after backing up the local side). On success
|
|
9
|
-
* it rewrites the base for the fields it actually reconciled and stamps
|
|
10
|
-
* `last_synced_at`. Body fields with no local frontmatter home yet are reported
|
|
11
|
-
* as `deferred` and their base is deliberately left pending (not falsely synced).
|
|
12
|
-
*
|
|
13
|
-
* Pure orchestration over an injected `adapter` (readProject) + injected
|
|
14
|
-
* `timestamp`; no clock, no MCP knowledge here (that's mcp.js). Tests drive it
|
|
15
|
-
* with a fake in-memory adapter.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
const { normalizeLocal, normalizeRemote } = require('./normalize.js')
|
|
19
|
-
const { classify } = require('./compare.js')
|
|
20
|
-
const { readBase, writeBase, backup } = require('./base.js')
|
|
21
|
-
const { writeFrontmatter } = require('./write.js')
|
|
22
|
-
const { frontmatterPatchFor } = require('./apply.js')
|
|
23
|
-
|
|
24
|
-
async function pull({ dir, snapshotDir, identifier, projectId, adapter, config, force = false, timestamp }) {
|
|
25
|
-
const local = normalizeLocal(snapshotDir, config)
|
|
26
|
-
const remoteRaw = await adapter.readProject(projectId)
|
|
27
|
-
if (!remoteRaw) {
|
|
28
|
-
return { ok: false, error: `Linear project not found: ${projectId}` }
|
|
29
|
-
}
|
|
30
|
-
const remote = normalizeRemote(remoteRaw, config)
|
|
31
|
-
const base = readBase(dir, identifier, config)
|
|
32
|
-
const fields = classify(local, remote, base, config)
|
|
33
|
-
|
|
34
|
-
const conflicts = fields.filter((f) => f.status === 'conflict').map((f) => f.field)
|
|
35
|
-
if (conflicts.length && !force) {
|
|
36
|
-
return {
|
|
37
|
-
ok: false,
|
|
38
|
-
blocked: true,
|
|
39
|
-
reason: 'conflict',
|
|
40
|
-
conflicts,
|
|
41
|
-
message: `pull refused — ${conflicts.length} field(s) changed on both sides: ` +
|
|
42
|
-
`${conflicts.join(', ')}. Resolve locally or re-run with --force (remote wins).`,
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Everything remote wants to write down: remote-only fields, plus (under force)
|
|
47
|
-
// both-conflict fields where remote wins.
|
|
48
|
-
const pullFields = fields.filter((f) => f.pullable)
|
|
49
|
-
const fieldValues = {}
|
|
50
|
-
for (const f of pullFields) fieldValues[f.field] = remote[f.field]
|
|
51
|
-
|
|
52
|
-
const { patch, applied, deferred } = frontmatterPatchFor(fieldValues, config)
|
|
53
|
-
|
|
54
|
-
// --force overwrites local edits — back the local side up first.
|
|
55
|
-
let backupPath = null
|
|
56
|
-
if (force) {
|
|
57
|
-
backupPath = backup('local', dir, identifier, config, { timestamp, data: local })
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// Apply frontmatter-mapped fields + stamp the sync.
|
|
61
|
-
if (applied.length || timestamp) {
|
|
62
|
-
writeFrontmatter(snapshotDir, config, { ...patch, last_synced_at: timestamp })
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Advance base only for reconciled fields; deferred (body) fields keep the
|
|
66
|
-
// local value as base so the remote edit stays pending, not marked synced.
|
|
67
|
-
const newBase = { ...local }
|
|
68
|
-
for (const field of applied) newBase[field] = remote[field]
|
|
69
|
-
newBase.__meta = { updatedAt: remoteRaw.updatedAt || null, syncedAt: timestamp }
|
|
70
|
-
const basePath = writeBase(dir, identifier, config, newBase)
|
|
71
|
-
|
|
72
|
-
return {
|
|
73
|
-
ok: true,
|
|
74
|
-
blocked: false,
|
|
75
|
-
applied,
|
|
76
|
-
deferred,
|
|
77
|
-
conflictsForced: force ? conflicts : [],
|
|
78
|
-
backupPath,
|
|
79
|
-
basePath,
|
|
80
|
-
pulled: pullFields.map((f) => f.field),
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
module.exports = { pull }
|
package/src/sync/push.js
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* `push` — repo → Linear, three-way aware and ownership-respecting.
|
|
5
|
-
*
|
|
6
|
-
* Never writes a `pull`-owned field or a `localOnlySection` (those aren't in the
|
|
7
|
-
* pushable set / the field set at all). Optimistic concurrency: if the remote has
|
|
8
|
-
* moved past the base — detected both by the classifier (any remote-only/conflict
|
|
9
|
-
* field) and by the recorded `updatedAt` — it aborts with "pull first" unless
|
|
10
|
-
* `--force`. It also **re-reads the remote immediately before writing** to catch a
|
|
11
|
-
* writer that raced in during the compare. `--force` makes local win after backing
|
|
12
|
-
* up the remote side. On success it rewrites the base and stamps `last_synced_at`.
|
|
13
|
-
*
|
|
14
|
-
* Pure orchestration over an injected `adapter` (readProject + updateProject) and
|
|
15
|
-
* injected `timestamp`. Tests drive it with a fake in-memory adapter.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
const { normalizeLocal, normalizeRemote } = require('./normalize.js')
|
|
19
|
-
const { classify } = require('./compare.js')
|
|
20
|
-
const { readBase, writeBase, backup } = require('./base.js')
|
|
21
|
-
const { writeFrontmatter } = require('./write.js')
|
|
22
|
-
|
|
23
|
-
async function push({ dir, snapshotDir, identifier, projectId, adapter, config, force = false, timestamp }) {
|
|
24
|
-
const local = normalizeLocal(snapshotDir, config)
|
|
25
|
-
const remoteRaw = await adapter.readProject(projectId)
|
|
26
|
-
if (!remoteRaw) {
|
|
27
|
-
return { ok: false, error: `Linear project not found: ${projectId}` }
|
|
28
|
-
}
|
|
29
|
-
const remote = normalizeRemote(remoteRaw, config)
|
|
30
|
-
const base = readBase(dir, identifier, config)
|
|
31
|
-
const baseStamp = base && base.__meta ? base.__meta.updatedAt : null
|
|
32
|
-
const fields = classify(local, remote, base, config)
|
|
33
|
-
|
|
34
|
-
// Remote moved past base if the classifier sees remote-side divergence OR the
|
|
35
|
-
// recorded updatedAt no longer matches (a change we can't even see as a field).
|
|
36
|
-
const remoteDivergedFields = fields
|
|
37
|
-
.filter((f) => f.raw === 'remote-only' || f.raw === 'conflict')
|
|
38
|
-
.map((f) => f.field)
|
|
39
|
-
const stampMoved = baseStamp != null && remoteRaw.updatedAt !== baseStamp
|
|
40
|
-
const moved = remoteDivergedFields.length > 0 || stampMoved
|
|
41
|
-
|
|
42
|
-
if (moved && !force) {
|
|
43
|
-
return {
|
|
44
|
-
ok: false,
|
|
45
|
-
blocked: true,
|
|
46
|
-
reason: 'remote-moved',
|
|
47
|
-
movedFields: remoteDivergedFields,
|
|
48
|
-
message:
|
|
49
|
-
'push refused — Linear moved since the last sync' +
|
|
50
|
-
(remoteDivergedFields.length ? ` (${remoteDivergedFields.join(', ')})` : '') +
|
|
51
|
-
'. Pull first, or re-run with --force (local wins).',
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const pushFields = fields.filter((f) => f.pushable)
|
|
56
|
-
if (!pushFields.length && !force) {
|
|
57
|
-
return { ok: true, blocked: false, written: [], skipped: [], note: 'nothing to push' }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// Optimistic concurrency: re-read immediately before writing to catch a racer.
|
|
61
|
-
const remoteRaw2 = await adapter.readProject(projectId)
|
|
62
|
-
if (remoteRaw2 && remoteRaw2.updatedAt !== remoteRaw.updatedAt && !force) {
|
|
63
|
-
return {
|
|
64
|
-
ok: false,
|
|
65
|
-
blocked: true,
|
|
66
|
-
reason: 'concurrent-write',
|
|
67
|
-
message: 'push refused — Linear changed during the push. Pull first, or --force.',
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// --force clobbers the remote side — back it up first.
|
|
72
|
-
let backupPath = null
|
|
73
|
-
if (force) {
|
|
74
|
-
backupPath = backup('remote', dir, identifier, config, { timestamp, data: remoteRaw2 || remoteRaw })
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const updates = {}
|
|
78
|
-
for (const f of pushFields) updates[f.field] = local[f.field]
|
|
79
|
-
const updated = (await adapter.updateProject(projectId, updates)) || remoteRaw2 || remoteRaw
|
|
80
|
-
const updatedRemote = normalizeRemote(updated, config)
|
|
81
|
-
|
|
82
|
-
// Reconciled base: local is the source of truth for the fields we pushed (and
|
|
83
|
-
// for unchanged/local-only fields); pull-owned fields keep Linear's value so
|
|
84
|
-
// they don't read as pending next time.
|
|
85
|
-
const newBase = { ...local }
|
|
86
|
-
for (const [field, own] of Object.entries(config.sync.fieldOwnership)) {
|
|
87
|
-
if (own === 'pull') newBase[field] = updatedRemote[field]
|
|
88
|
-
}
|
|
89
|
-
newBase.__meta = { updatedAt: updated.updatedAt || null, syncedAt: timestamp }
|
|
90
|
-
const basePath = writeBase(dir, identifier, config, newBase)
|
|
91
|
-
|
|
92
|
-
if (timestamp) writeFrontmatter(snapshotDir, config, { last_synced_at: timestamp })
|
|
93
|
-
|
|
94
|
-
return {
|
|
95
|
-
ok: true,
|
|
96
|
-
blocked: false,
|
|
97
|
-
written: pushFields.map((f) => f.field),
|
|
98
|
-
skipped: fields
|
|
99
|
-
.filter((f) => !f.pushable && f.status !== 'unchanged')
|
|
100
|
-
.map((f) => f.field),
|
|
101
|
-
backupPath,
|
|
102
|
-
basePath,
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
module.exports = { push }
|
package/src/sync/write.js
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Local snapshot writes for pull (Linear → repo).
|
|
5
|
-
*
|
|
6
|
-
* Phase 2 applies **frontmatter-mapped** pulled fields — the `pull`-owned data
|
|
7
|
-
* Linear genuinely owns (`workflowState` → `spec_status`, `priority`, `labels`)
|
|
8
|
-
* plus sync bookkeeping (`last_synced_at`, ids) — by surgically editing the YAML
|
|
9
|
-
* frontmatter of `00-overview.md` and leaving the markdown body byte-for-byte
|
|
10
|
-
* untouched. Existing keys are updated in place (order preserved); new keys are
|
|
11
|
-
* appended; a file with no frontmatter gets one prepended.
|
|
12
|
-
*
|
|
13
|
-
* Body/`both`-owned fields (`description`, `milestones`, …) are NOT written back
|
|
14
|
-
* here — that denormalizer is a tracked follow-up (see the spec). Callers advance
|
|
15
|
-
* the base only for fields they actually applied, so an un-applied remote edit
|
|
16
|
-
* stays pending rather than being silently marked synced.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
const fs = require('node:fs')
|
|
20
|
-
const path = require('node:path')
|
|
21
|
-
|
|
22
|
-
// Serialize a JS value as a YAML-ish frontmatter scalar. null/undefined → the
|
|
23
|
-
// key is dropped (caller shouldn't pass those).
|
|
24
|
-
function serialize(value) {
|
|
25
|
-
if (Array.isArray(value)) return JSON.stringify(value)
|
|
26
|
-
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
27
|
-
return JSON.stringify(String(value)) // quoted string
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// Split `---\n…\n---\n` frontmatter off the top. Returns { fmLines, body, had }.
|
|
31
|
-
function splitFrontmatter(raw) {
|
|
32
|
-
const m = /^---\n([\s\S]*?)\n---\n?/.exec(raw)
|
|
33
|
-
if (!m) return { fmLines: [], body: raw, had: false }
|
|
34
|
-
return { fmLines: m[1].split('\n'), body: raw.slice(m[0].length), had: true }
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Apply a key→value patch onto frontmatter lines, preserving order.
|
|
38
|
-
function patchFrontmatterLines(lines, patch) {
|
|
39
|
-
const keys = new Set(Object.keys(patch))
|
|
40
|
-
const out = []
|
|
41
|
-
const seen = new Set()
|
|
42
|
-
for (const line of lines) {
|
|
43
|
-
const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line)
|
|
44
|
-
if (kv && keys.has(kv[1])) {
|
|
45
|
-
out.push(`${kv[1]}: ${serialize(patch[kv[1]])}`)
|
|
46
|
-
seen.add(kv[1])
|
|
47
|
-
} else {
|
|
48
|
-
out.push(line)
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
// Append any new keys not already present.
|
|
52
|
-
for (const key of Object.keys(patch)) {
|
|
53
|
-
if (!seen.has(key)) out.push(`${key}: ${serialize(patch[key])}`)
|
|
54
|
-
}
|
|
55
|
-
return out
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Update `00-overview.md` frontmatter under `snapshotDir` with `patch`
|
|
60
|
-
* (key → value; nullish values are skipped). Returns the list of keys written.
|
|
61
|
-
*/
|
|
62
|
-
function writeFrontmatter(snapshotDir, config, patch) {
|
|
63
|
-
const overviewFile = (config && config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
|
|
64
|
-
const file = path.join(snapshotDir, overviewFile)
|
|
65
|
-
const raw = fs.readFileSync(file, 'utf-8')
|
|
66
|
-
|
|
67
|
-
const clean = {}
|
|
68
|
-
for (const [k, v] of Object.entries(patch)) {
|
|
69
|
-
if (v !== null && v !== undefined) clean[k] = v
|
|
70
|
-
}
|
|
71
|
-
if (!Object.keys(clean).length) return []
|
|
72
|
-
|
|
73
|
-
const { fmLines, body, had } = splitFrontmatter(raw)
|
|
74
|
-
const patched = patchFrontmatterLines(fmLines, clean)
|
|
75
|
-
const frontmatter = `---\n${patched.join('\n')}\n---\n`
|
|
76
|
-
const next = had ? frontmatter + body : frontmatter + '\n' + raw
|
|
77
|
-
|
|
78
|
-
fs.writeFileSync(file, next, 'utf-8')
|
|
79
|
-
return Object.keys(clean)
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
module.exports = {
|
|
83
|
-
writeFrontmatter,
|
|
84
|
-
splitFrontmatter,
|
|
85
|
-
serialize,
|
|
86
|
-
}
|