@skitterbyte/skitterspec-linear 10.1.0 → 10.2.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 +68 -23
- package/assets/core/SETUP.md +73 -13
- package/assets/core/linear.config.json.example +8 -1
- package/assets/core/linear.config.md +177 -10
- package/assets/rules/spec-planning.md +17 -11
- package/assets/skills/spec/SKILL.md +106 -66
- package/assets/skills/spec-bug/SKILL.md +99 -17
- package/assets/skills/spec-cancel/SKILL.md +34 -0
- package/assets/skills/spec-complete/SKILL.md +70 -15
- package/assets/skills/spec-hotfix/SKILL.md +157 -4
- package/assets/skills/spec-linear-setup/SKILL.md +172 -0
- package/assets/skills/spec-push/SKILL.md +108 -32
- package/assets/skills/spec-review/SKILL.md +34 -0
- package/assets/skills/spec-status/SKILL.md +9 -0
- package/bin/skitterspec-linear.js +19 -0
- package/package.json +1 -1
- package/src/cli.js +30 -19
- package/src/env/resolve.js +7 -2
- package/src/env/teardown.js +23 -9
- package/src/init.js +11 -1
- package/src/vendor/linear/api.js +246 -0
- package/src/vendor/linear/cli-sync.js +701 -3
- package/src/vendor/linear/config.js +116 -14
- package/src/vendor/sync-core/src/normalize.js +232 -85
- package/src/vendor/sync-core/src/push.js +10 -1
- package/src/vendor/sync-core/src/task-block.js +18 -7
|
@@ -44,7 +44,8 @@ const {
|
|
|
44
44
|
compareStored,
|
|
45
45
|
} = require('../sync-core')
|
|
46
46
|
|
|
47
|
-
const { loadLinearConfig } = require('./config.js')
|
|
47
|
+
const { loadLinearConfig, mergeConfig, defaults: configDefaults, CONFIG_FILE, LIFECYCLE_BUCKETS } = require('./config.js')
|
|
48
|
+
const { resolveApiKey, makeApiAdapter, stateIdFor, fetchWorkspaceStates } = require('./api.js')
|
|
48
49
|
|
|
49
50
|
// Resolve a spec argument to its snapshot dir. Accepts a spec name/folder found
|
|
50
51
|
// under specs/** (preferred) or a literal path to a snapshot directory.
|
|
@@ -198,6 +199,7 @@ function specSyncPush(dir, config, specArg, flags, out, err) {
|
|
|
198
199
|
const lines = [`spec-sync push: ${identifier}`, ...warningLines(snapshotDir, config)]
|
|
199
200
|
if (p.legacy) lines.push(...legacyLines(p.legacy))
|
|
200
201
|
if (p.phasesDeferred) lines.push(...deferredLines(p.phasesDeferred))
|
|
202
|
+
lines.push(...phaseModeLines(p.phaseMode, r.projection.status))
|
|
201
203
|
if (r.empty) lines.push(' nothing to push — mirror matches the last push')
|
|
202
204
|
else {
|
|
203
205
|
if (p.issue) lines.push(' issue: description/state')
|
|
@@ -219,6 +221,25 @@ function deferredLines(n) {
|
|
|
219
221
|
]
|
|
220
222
|
}
|
|
221
223
|
|
|
224
|
+
// Which phase mode resolved for this spec, and what it means for the plan below.
|
|
225
|
+
//
|
|
226
|
+
// Silent for `subissue`: the sub-issue lines are right there and explain
|
|
227
|
+
// themselves. Said for anything else, because `mapping.phases` can now be a
|
|
228
|
+
// per-bucket map — so the mode that applied is no longer readable off the config
|
|
229
|
+
// without knowing which bucket the spec is in, and the alternative reading of a
|
|
230
|
+
// spec with no sub-issues is that its phase files failed to parse.
|
|
231
|
+
function phaseModeLines(mode, bucket) {
|
|
232
|
+
if (!mode || mode === 'subissue') return []
|
|
233
|
+
const why = {
|
|
234
|
+
inline: 'each phase is a section of this issue\'s description, not a sub-issue',
|
|
235
|
+
deferred: 'unlinked phases are held back until the spec leaves backlog/cancelled',
|
|
236
|
+
}[mode]
|
|
237
|
+
const where = bucket ? ` for this spec's bucket ("${bucket}")` : ''
|
|
238
|
+
const lines = [` phases: ${mode} — mapping.phases resolved${where}`]
|
|
239
|
+
if (why) lines.push(` ${why}`)
|
|
240
|
+
return lines
|
|
241
|
+
}
|
|
242
|
+
|
|
222
243
|
// The pre-9.0 mirror block. Loud on purpose: the plan below it looks entirely
|
|
223
244
|
// ordinary — an all-creates plan for a spec that reads as unlinked — and
|
|
224
245
|
// applying it mints a second mirror and abandons the first.
|
|
@@ -419,6 +440,7 @@ function specSyncStatus(dir, config, specArg, flags, out) {
|
|
|
419
440
|
// From the projection, not the plan: `status` builds its plan with
|
|
420
441
|
// `planChanges` directly rather than going through `push`.
|
|
421
442
|
if (projection.phasesWithheld) lines.push(...deferredLines(projection.phasesWithheld))
|
|
443
|
+
lines.push(...phaseModeLines(projection.phaseMode, projection.status))
|
|
422
444
|
if (!snapshot) lines.push(' push: never pushed — everything is pending')
|
|
423
445
|
else if (isEmptyPlan(plan)) lines.push(' push: up to date — nothing changed since the last push')
|
|
424
446
|
else {
|
|
@@ -474,6 +496,18 @@ function specSyncVerify(dir, config, specArg, flags, out) {
|
|
|
474
496
|
}
|
|
475
497
|
|
|
476
498
|
const identifier = specIdentifier(snapshotDir, config)
|
|
499
|
+
out.write(verifyLines(snapshotDir, config, stored, identifier).join('\n') + '\n')
|
|
500
|
+
return 0
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* The verify comparison itself, as reportable lines.
|
|
505
|
+
*
|
|
506
|
+
* Split out of `specSyncVerify` so `apply` runs the SAME check on the read-back
|
|
507
|
+
* it does itself, rather than a second implementation that could disagree about
|
|
508
|
+
* what counts as lost text.
|
|
509
|
+
*/
|
|
510
|
+
function verifyLines(snapshotDir, config, stored, identifier) {
|
|
477
511
|
const projection = projectionOf(snapshotDir, config)
|
|
478
512
|
const checks = []
|
|
479
513
|
if (typeof stored.issue === 'string') checks.push(['issue', projection.description, stored.issue])
|
|
@@ -506,30 +540,681 @@ function specSyncVerify(dir, config, specArg, flags, out) {
|
|
|
506
540
|
}
|
|
507
541
|
if (!bad) lines.push(` ${checks.length} description(s) round-tripped intact`)
|
|
508
542
|
else lines.push(' the repo is unchanged and still correct; re-push to overwrite the mirror')
|
|
543
|
+
return lines
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* `spec-sync states [--json]` — which transport this repo will use, and on the
|
|
548
|
+
* API path the workspace's issue state NAMES.
|
|
549
|
+
*
|
|
550
|
+
* This exists to break a chicken-and-egg in `/spec-push`: the skill cannot know
|
|
551
|
+
* whether to do MCP work until it knows the transport, but `push` refuses to run
|
|
552
|
+
* without `--workspace-states`, which on the MCP path only an MCP call can
|
|
553
|
+
* supply. Asking the engine first makes the skill linear, and on the API path it
|
|
554
|
+
* removes the state fetch from the model's work entirely — the same reason
|
|
555
|
+
* `apply` exists.
|
|
556
|
+
*
|
|
557
|
+
* Read-only: fetches states, writes nothing, changes nothing.
|
|
558
|
+
*/
|
|
559
|
+
async function specSyncStates(dir, config, flags, out) {
|
|
560
|
+
const key = resolveApiKey(config, flags.env || process.env)
|
|
561
|
+
const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
|
|
562
|
+
|
|
563
|
+
if (transport === 'mcp') {
|
|
564
|
+
if (flags.json) {
|
|
565
|
+
out.write(JSON.stringify({ transport: 'mcp', reason: key.ok ? 'requested' : key.error, states: null }, null, 2) + '\n')
|
|
566
|
+
return 0
|
|
567
|
+
}
|
|
568
|
+
out.write(
|
|
569
|
+
[
|
|
570
|
+
'spec-sync states: transport = mcp',
|
|
571
|
+
` ${key.ok ? '--via mcp was requested' : key.error}`,
|
|
572
|
+
' fetch the workspace states over MCP, as /spec-push describes',
|
|
573
|
+
].join('\n') + '\n',
|
|
574
|
+
)
|
|
575
|
+
return 0
|
|
576
|
+
}
|
|
577
|
+
if (!key.ok) {
|
|
578
|
+
out.write(`spec-sync states: refusing — ${key.error}\n`)
|
|
579
|
+
return 1
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
|
|
583
|
+
const teamId = (config.linear && config.linear.teamId) || null
|
|
584
|
+
let names
|
|
585
|
+
try {
|
|
586
|
+
names = await fetchWorkspaceStates(adapter, teamId)
|
|
587
|
+
} catch (error) {
|
|
588
|
+
out.write(`spec-sync states: ${error.message}\n`)
|
|
589
|
+
return 1
|
|
590
|
+
}
|
|
591
|
+
if (flags.json) {
|
|
592
|
+
// The bare array `--workspace-states` takes, so this can be piped into it.
|
|
593
|
+
out.write(JSON.stringify(names, null, 2) + '\n')
|
|
594
|
+
return 0
|
|
595
|
+
}
|
|
596
|
+
out.write(`spec-sync states: transport = api\n ${names.join(', ')}\n`)
|
|
597
|
+
return 0
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* `spec-sync apply <spec> --plan <file> [--via api|mcp] [--project <id>]`
|
|
602
|
+
*
|
|
603
|
+
* Apply a push plan to Linear **without any description passing through the
|
|
604
|
+
* agent**. `/spec-push` used to hand the plan to the model, which re-emitted
|
|
605
|
+
* every description as generated tokens — twice, once to write and once for the
|
|
606
|
+
* verify read-back — making throughput a function of decode speed rather than of
|
|
607
|
+
* Linear's API. This does the writes, the read-back, the stamping and the
|
|
608
|
+
* snapshot in one call.
|
|
609
|
+
*
|
|
610
|
+
* Two guarantees shape the code:
|
|
611
|
+
*
|
|
612
|
+
* - **Nothing is written until everything is checked.** A legacy plan, a
|
|
613
|
+
* missing key, or a `config.states` name the workspace lacks all fail before
|
|
614
|
+
* the first mutation. A half-applied plan is the one outcome worse than an
|
|
615
|
+
* unapplied one.
|
|
616
|
+
* - **Each id is stamped the moment its object exists**, not batched at the
|
|
617
|
+
* end. An interrupted run therefore leaves the objects it did create linked,
|
|
618
|
+
* so the next run's plan sees them as updates and mints no duplicates. That
|
|
619
|
+
* is the whole resumability story — there is no separate ledger to disagree
|
|
620
|
+
* with the spec files.
|
|
621
|
+
*
|
|
622
|
+
* On the MCP transport it writes nothing and prints the plan for the skill to
|
|
623
|
+
* apply, exactly as before.
|
|
624
|
+
*/
|
|
625
|
+
/**
|
|
626
|
+
* `spec-sync projects [--json]` — the team's Linear Projects, for the picker.
|
|
627
|
+
*
|
|
628
|
+
* The picker is the one interactive step in linking a spec, and on the API path
|
|
629
|
+
* there is no MCP tool to list from — the whole point of that path is that the
|
|
630
|
+
* agent makes no Linear calls. So the engine offers the list, exactly as
|
|
631
|
+
* `spec-sync states` offers workspace states.
|
|
632
|
+
*
|
|
633
|
+
* Degrades rather than blocks: no key, or a Linear that will not answer, exits 0
|
|
634
|
+
* with an empty list and says why. A missing picker must never fail `/spec`.
|
|
635
|
+
*/
|
|
636
|
+
async function specSyncProjects(dir, config, flags, out) {
|
|
637
|
+
const key = resolveApiKey(config, flags.env || process.env)
|
|
638
|
+
const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
|
|
639
|
+
const teamId = (config.linear && config.linear.teamId) || null
|
|
640
|
+
|
|
641
|
+
const degrade = (reason) => {
|
|
642
|
+
if (flags.json) out.write(JSON.stringify({ transport, projects: null, reason }, null, 2) + '\n')
|
|
643
|
+
else out.write(`spec-sync projects: ${reason}\n`)
|
|
644
|
+
return 0
|
|
645
|
+
}
|
|
646
|
+
if (transport === 'mcp') {
|
|
647
|
+
return degrade(
|
|
648
|
+
`transport = mcp — ${key.ok ? '--via mcp was requested' : key.error}; list projects over MCP instead`,
|
|
649
|
+
)
|
|
650
|
+
}
|
|
651
|
+
if (!key.ok) return degrade(key.error)
|
|
652
|
+
|
|
653
|
+
const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
|
|
654
|
+
let projects
|
|
655
|
+
try {
|
|
656
|
+
projects = await adapter.listProjects(teamId)
|
|
657
|
+
} catch (error) {
|
|
658
|
+
// The picker's contract is "degrade, never block" — a project list we cannot
|
|
659
|
+
// fetch means no picker, not a failed link.
|
|
660
|
+
return degrade(`could not list projects (${error.message}); continuing without the picker`)
|
|
661
|
+
}
|
|
662
|
+
const rows = projects.map((p) => ({ id: p.id, name: p.name })).filter((p) => p.id)
|
|
663
|
+
if (flags.json) {
|
|
664
|
+
out.write(JSON.stringify({ transport: 'api', projects: rows }, null, 2) + '\n')
|
|
665
|
+
return 0
|
|
666
|
+
}
|
|
667
|
+
out.write(
|
|
668
|
+
[`spec-sync projects: transport = api, ${rows.length} project(s)`, ...rows.map((p) => ` ${p.id} ${p.name}`)].join('\n') + '\n',
|
|
669
|
+
)
|
|
670
|
+
return 0
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Apply one spec's plan. Returns what happened rather than printing it, so the
|
|
675
|
+
* single-spec command and the bulk loop report in their own voices while sharing
|
|
676
|
+
* one implementation of the part that actually matters.
|
|
677
|
+
*
|
|
678
|
+
* Every id is stamped the moment its object exists — see `specSyncApply`.
|
|
679
|
+
*/
|
|
680
|
+
async function applyOneSpec({ dir, config, snapshotDir, plan, adapter, teamId, project, states }) {
|
|
681
|
+
const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
|
|
682
|
+
const identifier = linkedIdentifier(path.join(snapshotDir, overviewFile))
|
|
683
|
+
const lines = []
|
|
684
|
+
const result = { issue: null, subIssues: {} }
|
|
685
|
+
const stateId = (bucket) => (bucket ? stateIdFor(bucket, config, states) : null)
|
|
686
|
+
|
|
687
|
+
// Resolve every state id BEFORE the first write, so a bad config.states value
|
|
688
|
+
// cannot strand the spec mid-apply.
|
|
689
|
+
const wanted = new Set()
|
|
690
|
+
if (plan.issue && plan.issue.state) wanted.add(plan.issue.state)
|
|
691
|
+
for (const s of (plan.subIssues && plan.subIssues.create) || []) if (s.state) wanted.add(s.state)
|
|
692
|
+
for (const s of (plan.subIssues && plan.subIssues.update) || []) if (s.state) wanted.add(s.state)
|
|
693
|
+
for (const bucket of wanted) stateId(bucket)
|
|
694
|
+
|
|
695
|
+
// 1. The spec issue. No identifier yet → this push mints it.
|
|
696
|
+
let parentId = null
|
|
697
|
+
if (!identifier) {
|
|
698
|
+
const created = await adapter.createIssue(withoutNull({
|
|
699
|
+
title: readSnapshot(snapshotDir, config).title,
|
|
700
|
+
teamId,
|
|
701
|
+
projectId: project || (config.linear && config.linear.projectId) || null,
|
|
702
|
+
description: plan.issue && plan.issue.description,
|
|
703
|
+
stateId: stateId(plan.issue && plan.issue.state),
|
|
704
|
+
}))
|
|
705
|
+
if (!created || !created.identifier) throw new Error('Linear returned no issue for the spec create')
|
|
706
|
+
parentId = created.id
|
|
707
|
+
// Stamped NOW: an interrupt after this point must not mint a second issue.
|
|
708
|
+
writeFrontmatter(snapshotDir, config, { linear_identifier: created.identifier, linear_url: created.url })
|
|
709
|
+
result.issue = { id: created.id, identifier: created.identifier, url: created.url }
|
|
710
|
+
lines.push(` issue created: ${created.identifier}`)
|
|
711
|
+
} else {
|
|
712
|
+
const existing = await adapter.readIssue(identifier)
|
|
713
|
+
if (!existing || !existing.id) throw new Error(`no Linear issue found for ${identifier}`)
|
|
714
|
+
parentId = existing.id
|
|
715
|
+
result.issue = { id: existing.id, identifier: existing.identifier, url: existing.url }
|
|
716
|
+
if (plan.issue) {
|
|
717
|
+
await adapter.updateIssue(existing.id, withoutNull({
|
|
718
|
+
description: plan.issue.description,
|
|
719
|
+
stateId: stateId(plan.issue.state),
|
|
720
|
+
}))
|
|
721
|
+
lines.push(` issue updated: ${identifier}`)
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// 2. Sub-issue creates — each stamped as it lands, for the same reason.
|
|
726
|
+
for (const sub of (plan.subIssues && plan.subIssues.create) || []) {
|
|
727
|
+
const created = await adapter.createSubIssue(parentId, withoutNull({
|
|
728
|
+
title: sub.name,
|
|
729
|
+
teamId,
|
|
730
|
+
description: sub.goal,
|
|
731
|
+
stateId: stateId(sub.state),
|
|
732
|
+
}))
|
|
733
|
+
if (!created || !created.identifier) throw new Error(`Linear returned no issue for sub-issue ${sub.ref}`)
|
|
734
|
+
const file = resolvePhaseFile(snapshotDir, sub.ref)
|
|
735
|
+
if (!file) throw new Error(`no phase file for ref ${sub.ref}`)
|
|
736
|
+
stampSubIssueId(snapshotDir, file, created.identifier)
|
|
737
|
+
result.subIssues[sub.ref] = created.identifier
|
|
738
|
+
lines.push(` sub-issue created: ${sub.ref} → ${created.identifier}`)
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// 3. Sub-issue updates — already linked, nothing to stamp.
|
|
742
|
+
for (const sub of (plan.subIssues && plan.subIssues.update) || []) {
|
|
743
|
+
await adapter.updateIssue(sub.id, withoutNull({
|
|
744
|
+
title: sub.name,
|
|
745
|
+
description: sub.goal,
|
|
746
|
+
stateId: stateId(sub.state),
|
|
747
|
+
}))
|
|
748
|
+
result.subIssues[sub.ref || sub.id] = sub.id
|
|
749
|
+
lines.push(` sub-issue updated: ${sub.id}`)
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// 4. Read back what Linear stored and run the SAME check `verify` runs.
|
|
753
|
+
const stored = { issue: undefined, subIssues: {} }
|
|
754
|
+
if (result.issue) {
|
|
755
|
+
const back = await adapter.readIssue(result.issue.id)
|
|
756
|
+
if (back && typeof back.description === 'string') stored.issue = back.description
|
|
757
|
+
}
|
|
758
|
+
for (const [ref, id] of Object.entries(result.subIssues)) {
|
|
759
|
+
const back = await adapter.readIssue(id)
|
|
760
|
+
if (back && typeof back.description === 'string') stored.subIssues[ref] = back.description
|
|
761
|
+
}
|
|
762
|
+
const verify = verifyLines(snapshotDir, config, stored, result.issue ? result.issue.identifier : identifier)
|
|
763
|
+
lines.push(...verify.map((l) => ` ${l}`))
|
|
764
|
+
const lost = verify.some((l) => l.includes('!!') || l.includes('??'))
|
|
765
|
+
|
|
766
|
+
// 5. Record the snapshot from the now-stamped files, so the next push is empty.
|
|
767
|
+
const file = recordPush({ dir, snapshotDir, identifier: specIdentifier(snapshotDir, config), config })
|
|
768
|
+
lines.push(` snapshot: ${path.relative(dir, file)}`)
|
|
769
|
+
return { result, lines, lost }
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* `spec-sync apply <spec> --plan <file> [--via api|mcp] [--project <id>]`
|
|
774
|
+
* `spec-sync apply --all <bucket> [--via api|mcp] [--json]`
|
|
775
|
+
*
|
|
776
|
+
* Apply a push plan to Linear **without any description passing through the
|
|
777
|
+
* agent**. `/spec-push` used to hand the plan to the model, which re-emitted
|
|
778
|
+
* every description as generated tokens — twice, once to write and once for the
|
|
779
|
+
* verify read-back — making throughput a function of decode speed rather than of
|
|
780
|
+
* Linear's API. This does the writes, the read-back, the stamping and the
|
|
781
|
+
* snapshot in one call.
|
|
782
|
+
*
|
|
783
|
+
* Two guarantees shape the code:
|
|
784
|
+
*
|
|
785
|
+
* - **Nothing is written until everything is checked.** A legacy plan, a
|
|
786
|
+
* missing key, or a `config.states` name the workspace lacks all fail before
|
|
787
|
+
* the first mutation. A half-applied plan is the one outcome worse than an
|
|
788
|
+
* unapplied one.
|
|
789
|
+
* - **Each id is stamped the moment its object exists**, not batched at the
|
|
790
|
+
* end. An interrupted run therefore leaves the objects it did create linked,
|
|
791
|
+
* so the next run's plan sees them as updates and mints no duplicates. That
|
|
792
|
+
* is the whole resumability story — there is no separate ledger to disagree
|
|
793
|
+
* with the spec files.
|
|
794
|
+
*
|
|
795
|
+
* `--all <bucket>` walks every spec in one lifecycle bucket, computing each plan
|
|
796
|
+
* in process (no plan files) and carrying on past a spec that fails. That is
|
|
797
|
+
* first-time adoption on an established repo: one command instead of a session.
|
|
798
|
+
*
|
|
799
|
+
* On the MCP transport it writes nothing and prints the plan for the skill to
|
|
800
|
+
* apply, exactly as before.
|
|
801
|
+
*/
|
|
802
|
+
async function specSyncApply(dir, config, specArg, flags, out) {
|
|
803
|
+
const bulk = flags.all != null
|
|
804
|
+
if (bulk && !BUCKETS.includes(flags.all)) {
|
|
805
|
+
out.write(`spec-sync apply: --all ${flags.all} is not a bucket (${BUCKETS.join('|')})\n`)
|
|
806
|
+
return 1
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
let snapshotDir = null
|
|
810
|
+
let plan = null
|
|
811
|
+
if (!bulk) {
|
|
812
|
+
snapshotDir = resolveOrExit(specArg, dir, out)
|
|
813
|
+
if (!snapshotDir) return 1
|
|
814
|
+
if (!flags.plan) {
|
|
815
|
+
out.write(
|
|
816
|
+
'spec-sync apply: refusing to run without --plan <file>.\n' +
|
|
817
|
+
' Get one with: skitterspec spec-sync push <spec> --json > plan.json\n' +
|
|
818
|
+
' Or apply a whole bucket at once with --all <bucket>.\n',
|
|
819
|
+
)
|
|
820
|
+
return 1
|
|
821
|
+
}
|
|
822
|
+
try {
|
|
823
|
+
plan = JSON.parse(fs.readFileSync(flags.plan, 'utf-8'))
|
|
824
|
+
} catch (error) {
|
|
825
|
+
out.write(`spec-sync apply: cannot read --plan ${flags.plan}: ${error.message}\n`)
|
|
826
|
+
return 1
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// A pre-9.0 mirror reads as unlinked, so its plan is all-creates and applying
|
|
830
|
+
// it would abandon the live objects. The API path must not do that faster
|
|
831
|
+
// than a human can read about it.
|
|
832
|
+
if (plan.legacy) {
|
|
833
|
+
out.write(
|
|
834
|
+
['spec-sync apply: refusing — this spec is linked under the pre-9.0 model.', ...legacyLines(plan.legacy)].join('\n') + '\n',
|
|
835
|
+
)
|
|
836
|
+
return 1
|
|
837
|
+
}
|
|
838
|
+
if (isEmptyPlan(plan)) {
|
|
839
|
+
out.write('spec-sync apply: nothing to apply — the mirror is up to date.\n')
|
|
840
|
+
return 0
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const key = resolveApiKey(config, flags.env || process.env)
|
|
845
|
+
const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
|
|
846
|
+
|
|
847
|
+
if (transport === 'mcp') {
|
|
848
|
+
if (bulk) {
|
|
849
|
+
// Bulk over MCP is the very thing this command exists to avoid; pretending
|
|
850
|
+
// to support it would hand the model every description in the bucket.
|
|
851
|
+
out.write(
|
|
852
|
+
[
|
|
853
|
+
'spec-sync apply: --all needs the api transport (no writes made here)',
|
|
854
|
+
` ${key.ok ? '--via mcp was requested' : key.error}`,
|
|
855
|
+
' push specs one at a time with /spec-push, or set a key and re-run.',
|
|
856
|
+
].join('\n') + '\n',
|
|
857
|
+
)
|
|
858
|
+
return 1
|
|
859
|
+
}
|
|
860
|
+
out.write(
|
|
861
|
+
[
|
|
862
|
+
'spec-sync apply: transport = mcp (no writes made here)',
|
|
863
|
+
key.ok ? ' --via mcp was requested' : ` ${key.error}`,
|
|
864
|
+
' apply the plan over MCP as /spec-push describes, then run:',
|
|
865
|
+
' skitterspec spec-sync stamp <spec> --issue … [--sub <ref>=… …]',
|
|
866
|
+
' skitterspec spec-sync record <spec>',
|
|
867
|
+
].join('\n') + '\n',
|
|
868
|
+
)
|
|
869
|
+
return 0
|
|
870
|
+
}
|
|
871
|
+
if (!key.ok) {
|
|
872
|
+
// --via api (or apply.transport: api) was explicit, so this is a failure
|
|
873
|
+
// rather than a quiet fallback — and it happens before any write.
|
|
874
|
+
out.write(`spec-sync apply: refusing — ${key.error}\n`)
|
|
875
|
+
return 1
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
|
|
879
|
+
const teamId = (config.linear && config.linear.teamId) || null
|
|
880
|
+
let states
|
|
881
|
+
try {
|
|
882
|
+
states = await adapter.listIssueStates(teamId)
|
|
883
|
+
} catch (error) {
|
|
884
|
+
out.write(`spec-sync apply: ${error.message}\n`)
|
|
885
|
+
return 1
|
|
886
|
+
}
|
|
887
|
+
const shared = { dir, config, adapter, teamId, project: flags.project, states }
|
|
888
|
+
|
|
889
|
+
if (!bulk) {
|
|
890
|
+
const lines = ['spec-sync apply: transport = api']
|
|
891
|
+
let applied
|
|
892
|
+
try {
|
|
893
|
+
applied = await applyOneSpec({ ...shared, snapshotDir, plan })
|
|
894
|
+
} catch (error) {
|
|
895
|
+
// Whatever landed before the failure is already stamped, so re-running
|
|
896
|
+
// resumes rather than duplicating — say so instead of leaving it ambiguous.
|
|
897
|
+
out.write(
|
|
898
|
+
[...lines, ` !! ${error.message}`, ' ids stamped so far are saved — re-run to resume without duplicating'].join('\n') + '\n',
|
|
899
|
+
)
|
|
900
|
+
return 1
|
|
901
|
+
}
|
|
902
|
+
if (flags.json) {
|
|
903
|
+
out.write(JSON.stringify(applied.result, null, 2) + '\n')
|
|
904
|
+
return 0
|
|
905
|
+
}
|
|
906
|
+
out.write([...lines, ...applied.lines].join('\n') + '\n')
|
|
907
|
+
return 0
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// --- bulk -------------------------------------------------------------------
|
|
911
|
+
const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
|
|
912
|
+
const specs = listSpecs(dir, config).filter((s) => s.bucket === flags.all)
|
|
913
|
+
const lines = [`spec-sync apply --all ${flags.all}: transport = api, ${specs.length} spec(s)`]
|
|
914
|
+
const summary = { created: 0, updated: 0, upToDate: 0, failed: 0, altered: 0 }
|
|
915
|
+
const failures = []
|
|
916
|
+
|
|
917
|
+
const fail = (spec, why) => {
|
|
918
|
+
summary.failed++
|
|
919
|
+
failures.push(`${spec}: ${why}`)
|
|
920
|
+
lines.push(` x ${spec}: ${why}`)
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
for (const { spec } of specs) {
|
|
924
|
+
const specDir = resolveSnapshotDir(spec, dir)
|
|
925
|
+
if (!specDir) {
|
|
926
|
+
fail(spec, 'could not resolve its folder')
|
|
927
|
+
continue
|
|
928
|
+
}
|
|
929
|
+
let specPlan
|
|
930
|
+
try {
|
|
931
|
+
specPlan = push({ dir, snapshotDir: specDir, identifier: specIdentifier(specDir, config), config }).plan
|
|
932
|
+
} catch (error) {
|
|
933
|
+
fail(spec, error.message)
|
|
934
|
+
continue
|
|
935
|
+
}
|
|
936
|
+
// Never silently: applying this would abandon a live pre-9.0 mirror.
|
|
937
|
+
if (specPlan.legacy) {
|
|
938
|
+
fail(spec, `pre-9.0 mirror — would orphan ${specPlan.legacy.orphanCount} object(s); see MIGRATION.md`)
|
|
939
|
+
continue
|
|
940
|
+
}
|
|
941
|
+
if (isEmptyPlan(specPlan)) {
|
|
942
|
+
summary.upToDate++
|
|
943
|
+
lines.push(` . ${spec}: up to date`)
|
|
944
|
+
continue
|
|
945
|
+
}
|
|
946
|
+
const minted = !linkedIdentifier(path.join(specDir, overviewFile))
|
|
947
|
+
let applied
|
|
948
|
+
try {
|
|
949
|
+
applied = await applyOneSpec({ ...shared, snapshotDir: specDir, plan: specPlan })
|
|
950
|
+
} catch (error) {
|
|
951
|
+
fail(spec, error.message)
|
|
952
|
+
continue
|
|
953
|
+
}
|
|
954
|
+
if (minted) summary.created++
|
|
955
|
+
else summary.updated++
|
|
956
|
+
const id = applied.result.issue ? applied.result.issue.identifier : '?'
|
|
957
|
+
const subs = Object.keys(applied.result.subIssues).length
|
|
958
|
+
lines.push(` ok ${spec}: ${minted ? 'created' : 'updated'} ${id}${subs ? ` (+${subs} sub-issue(s))` : ''}`)
|
|
959
|
+
if (applied.lost) {
|
|
960
|
+
summary.altered++
|
|
961
|
+
lines.push(...applied.lines.filter((l) => l.includes('!!') || l.includes('??')))
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
lines.push(
|
|
966
|
+
'',
|
|
967
|
+
` created ${summary.created} · updated ${summary.updated} · up to date ${summary.upToDate} · failed ${summary.failed}`,
|
|
968
|
+
)
|
|
969
|
+
if (summary.altered) {
|
|
970
|
+
lines.push(` ${summary.altered} spec(s) had text altered by Linear — the repo is unchanged and still correct`)
|
|
971
|
+
}
|
|
972
|
+
if (failures.length) {
|
|
973
|
+
lines.push('', ' failed:')
|
|
974
|
+
for (const f of failures) lines.push(` ${f}`)
|
|
975
|
+
lines.push(' re-run to retry them — everything already created is linked, so nothing duplicates')
|
|
976
|
+
}
|
|
977
|
+
if (flags.json) out.write(JSON.stringify({ summary, failures }, null, 2) + '\n')
|
|
978
|
+
else out.write(lines.join('\n') + '\n')
|
|
979
|
+
// Non-zero when anything failed, so a scripted backfill is checkable.
|
|
980
|
+
return summary.failed ? 1 : 0
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// A comma-separated label flag (`--bug-labels bug,defect`) as the trimmed,
|
|
984
|
+
// non-empty list the config stores.
|
|
985
|
+
function labelList(value) {
|
|
986
|
+
return String(value || '')
|
|
987
|
+
.split(',')
|
|
988
|
+
.map((s) => s.trim())
|
|
989
|
+
.filter(Boolean)
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* `spec-sync init-config --team-id <id> [flags]`
|
|
994
|
+
*
|
|
995
|
+
* Turn values gathered from the workspace into a valid
|
|
996
|
+
* `specs/.core/linear.config.json`. The engine half of `/spec-linear-setup`:
|
|
997
|
+
* the skill discovers over MCP and interviews, this validates and writes, so a
|
|
998
|
+
* malformed config can never be the model's formatting. Same split as
|
|
999
|
+
* `/spec-push` gathering and `spec-sync apply` writing.
|
|
1000
|
+
*
|
|
1001
|
+
* Three things shape it:
|
|
1002
|
+
*
|
|
1003
|
+
* - **Only the keys the operator set are written.** The shipped defaults
|
|
1004
|
+
* already carry every other value; restating them buries the two or three
|
|
1005
|
+
* lines that are genuinely this repo's, and freezes today's defaults into a
|
|
1006
|
+
* file that then never picks up a change to them.
|
|
1007
|
+
* - **Nothing is written until the loader would accept it.** The draft is run
|
|
1008
|
+
* through `mergeConfig` — the very function `loadLinearConfig` uses — so an
|
|
1009
|
+
* enum this rejects is exactly an enum that would have thrown on first use.
|
|
1010
|
+
* - **State names are checked here, not at first push.** Linear silently
|
|
1011
|
+
* ignores an unknown issue state, so a workspace that renamed `Done` gets a
|
|
1012
|
+
* mirror that never moves. `--states` makes that a setup-time failure with
|
|
1013
|
+
* the replacement named.
|
|
1014
|
+
*
|
|
1015
|
+
* `--states` is optional, and deliberately so: `spec-sync states` cannot run
|
|
1016
|
+
* before a config exists (every other subcommand exits early on `present:false`),
|
|
1017
|
+
* so requiring it here would make the config unbootstrappable. The skill always
|
|
1018
|
+
* passes it from MCP discovery; a bare CLI run without it writes and says
|
|
1019
|
+
* loudly that the names are unverified.
|
|
1020
|
+
*/
|
|
1021
|
+
function specSyncInitConfig(dir, flags, out) {
|
|
1022
|
+
const file = path.join(dir, CONFIG_FILE)
|
|
1023
|
+
const rel = CONFIG_FILE
|
|
1024
|
+
const existed = fs.existsSync(file)
|
|
1025
|
+
const fail = (lines, extra = {}) => {
|
|
1026
|
+
if (flags.json) {
|
|
1027
|
+
out.write(JSON.stringify({ ok: false, file: rel, error: lines[0], ...extra }, null, 2) + '\n')
|
|
1028
|
+
} else {
|
|
1029
|
+
out.write(lines.join('\n') + '\n')
|
|
1030
|
+
}
|
|
1031
|
+
return 1
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
if (existed && !flags.force) {
|
|
1035
|
+
return fail([
|
|
1036
|
+
`spec-sync init-config: refusing — ${rel} already exists`,
|
|
1037
|
+
' pass --force to replace it. Re-running setup on a configured repo is',
|
|
1038
|
+
' meant to be a way to CHECK the setup, not a way to lose it.',
|
|
1039
|
+
])
|
|
1040
|
+
}
|
|
1041
|
+
if (!flags.teamId) {
|
|
1042
|
+
return fail([
|
|
1043
|
+
'spec-sync init-config: refusing — --team-id is required',
|
|
1044
|
+
' it is the one value with no useful default: without it nothing knows',
|
|
1045
|
+
' which Linear team a spec files into.',
|
|
1046
|
+
])
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Only what the operator actually set. Empty strings/arrays are treated as
|
|
1050
|
+
// "not set" — the defaults already say that, and more clearly.
|
|
1051
|
+
const draft = {}
|
|
1052
|
+
const linear = {}
|
|
1053
|
+
if (flags.teamId) linear.teamId = flags.teamId
|
|
1054
|
+
if (flags.teamKey) linear.teamKey = flags.teamKey
|
|
1055
|
+
if (flags.projectId) linear.projectId = flags.projectId
|
|
1056
|
+
draft.linear = linear
|
|
1057
|
+
const intake = {}
|
|
1058
|
+
if (flags.intakeLabel) intake.label = flags.intakeLabel
|
|
1059
|
+
if (flags.bugLabels.length) intake.bugLabels = flags.bugLabels
|
|
1060
|
+
if (flags.hotfixLabels.length) intake.hotfixLabels = flags.hotfixLabels
|
|
1061
|
+
if (Object.keys(intake).length) draft.intake = intake
|
|
1062
|
+
if (Object.keys(flags.stateNames).length) draft.states = { ...flags.stateNames }
|
|
1063
|
+
|
|
1064
|
+
for (const bucket of Object.keys(flags.stateNames)) {
|
|
1065
|
+
if (!LIFECYCLE_BUCKETS.includes(bucket)) {
|
|
1066
|
+
return fail([
|
|
1067
|
+
`spec-sync init-config: refusing — --state ${bucket}=… is not a lifecycle bucket`,
|
|
1068
|
+
` expected one of ${LIFECYCLE_BUCKETS.join(', ')}`,
|
|
1069
|
+
])
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// The loader's own merge, so anything it would reject is rejected now rather
|
|
1074
|
+
// than on the first command that reads the file.
|
|
1075
|
+
let effective
|
|
1076
|
+
try {
|
|
1077
|
+
effective = mergeConfig(configDefaults(), draft)
|
|
1078
|
+
} catch (error) {
|
|
1079
|
+
return fail([`spec-sync init-config: refusing — ${error.message}`])
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// Workspace state names, when the caller discovered them.
|
|
1083
|
+
let workspace = null
|
|
1084
|
+
if (flags.statesFile) {
|
|
1085
|
+
if (!fs.existsSync(flags.statesFile)) {
|
|
1086
|
+
return fail([`spec-sync init-config: refusing — no such --states file: ${flags.statesFile}`])
|
|
1087
|
+
}
|
|
1088
|
+
let parsed
|
|
1089
|
+
try {
|
|
1090
|
+
parsed = JSON.parse(fs.readFileSync(flags.statesFile, 'utf-8'))
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
return fail([`spec-sync init-config: refusing — --states is not valid JSON: ${error.message}`])
|
|
1093
|
+
}
|
|
1094
|
+
if (!Array.isArray(parsed)) {
|
|
1095
|
+
return fail(['spec-sync init-config: refusing — --states must be a JSON array of state names'])
|
|
1096
|
+
}
|
|
1097
|
+
workspace = parsed.map((s) => String(s)).filter(Boolean)
|
|
1098
|
+
|
|
1099
|
+
const missing = validateStates(effective, workspace)
|
|
1100
|
+
if (missing.length) {
|
|
1101
|
+
const lines = ['spec-sync init-config: refusing — configured state name(s) not in the workspace', '']
|
|
1102
|
+
for (const { bucket, configured, suggestion } of stateSuggestions(effective, workspace)) {
|
|
1103
|
+
lines.push(` states.${bucket}: "${configured}" is not an issue state in this workspace`)
|
|
1104
|
+
if (suggestion) lines.push(` pass --state ${bucket}="${suggestion}"`)
|
|
1105
|
+
}
|
|
1106
|
+
lines.push(
|
|
1107
|
+
'',
|
|
1108
|
+
` available: ${workspace.join(', ') || '(the workspace reported none)'}`,
|
|
1109
|
+
' Linear silently ignores an unknown issue state, so writing this would',
|
|
1110
|
+
' have produced a mirror that never moves.',
|
|
1111
|
+
)
|
|
1112
|
+
return fail(lines, { missing })
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
1117
|
+
fs.writeFileSync(file, JSON.stringify(draft, null, 2) + '\n', 'utf-8')
|
|
1118
|
+
|
|
1119
|
+
const checked = Object.keys(effective.states).length
|
|
1120
|
+
if (flags.json) {
|
|
1121
|
+
out.write(
|
|
1122
|
+
JSON.stringify(
|
|
1123
|
+
{
|
|
1124
|
+
ok: true,
|
|
1125
|
+
file: rel,
|
|
1126
|
+
replaced: existed,
|
|
1127
|
+
wrote: draft,
|
|
1128
|
+
validated: {
|
|
1129
|
+
teamId: flags.teamId,
|
|
1130
|
+
states: workspace ? { checked, against: workspace.length, missing: [] } : null,
|
|
1131
|
+
},
|
|
1132
|
+
},
|
|
1133
|
+
null,
|
|
1134
|
+
2,
|
|
1135
|
+
) + '\n',
|
|
1136
|
+
)
|
|
1137
|
+
return 0
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const lines = [`spec-sync init-config: wrote ${rel}`]
|
|
1141
|
+
lines.push(` team: ${flags.teamKey ? `${flags.teamKey} · ` : ''}${flags.teamId}`)
|
|
1142
|
+
lines.push(` project: ${flags.projectId || '(none — team only; the picker offers the rest)'}`)
|
|
1143
|
+
if (draft.intake) {
|
|
1144
|
+
const bits = []
|
|
1145
|
+
if (intake.label) bits.push(`inbox "${intake.label}"`)
|
|
1146
|
+
if (intake.bugLabels) bits.push(`bug: ${intake.bugLabels.join(', ')}`)
|
|
1147
|
+
if (intake.hotfixLabels) bits.push(`hotfix: ${intake.hotfixLabels.join(', ')}`)
|
|
1148
|
+
lines.push(` intake: ${bits.join(' · ')}`)
|
|
1149
|
+
}
|
|
1150
|
+
if (workspace) {
|
|
1151
|
+
lines.push(` states: ${checked} checked against ${workspace.length} workspace state(s) — all present`)
|
|
1152
|
+
} else {
|
|
1153
|
+
lines.push(` states: NOT validated — the defaults (${Object.values(effective.states).join(', ')}) are assumed`)
|
|
1154
|
+
lines.push(' pass --states <file> to check them; a renamed state pushes clean and moves nothing')
|
|
1155
|
+
}
|
|
1156
|
+
lines.push(` Wrote ${Object.keys(draft).length} section(s); everything else uses the shipped defaults.`)
|
|
509
1157
|
out.write(lines.join('\n') + '\n')
|
|
510
1158
|
return 0
|
|
511
1159
|
}
|
|
512
1160
|
|
|
1161
|
+
// Drop null/undefined so a GraphQL input never carries a key it has no value
|
|
1162
|
+
// for — Linear rejects an explicit null where it expects a field to be absent.
|
|
1163
|
+
function withoutNull(obj) {
|
|
1164
|
+
const out = {}
|
|
1165
|
+
for (const [k, v] of Object.entries(obj)) if (v !== null && v !== undefined) out[k] = v
|
|
1166
|
+
return out
|
|
1167
|
+
}
|
|
1168
|
+
|
|
513
1169
|
async function specSync(rest, io = {}) {
|
|
514
1170
|
const out = io.out || process.stdout
|
|
515
1171
|
const err = io.err || process.stderr
|
|
516
1172
|
const [sub, ...args] = rest
|
|
517
1173
|
let dir = io.cwd || process.cwd()
|
|
518
1174
|
const positional = []
|
|
519
|
-
const flags = { json: false, remote: null, workspaceStates: null, skipStateCheck: false, issue: null, url: null, subs: [], stored: null
|
|
1175
|
+
const flags = { json: false, remote: null, workspaceStates: null, skipStateCheck: false, issue: null, url: null, subs: [], stored: null, plan: null, via: null, project: null, all: null,
|
|
1176
|
+
force: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null }
|
|
520
1177
|
for (let i = 0; i < args.length; i++) {
|
|
521
1178
|
if (args[i] === '--dir') dir = path.resolve(args[++i])
|
|
522
1179
|
else if (args[i] === '--json') flags.json = true
|
|
523
1180
|
else if (args[i] === '--remote') flags.remote = path.resolve(args[++i])
|
|
524
1181
|
else if (args[i] === '--stored') flags.stored = path.resolve(args[++i])
|
|
1182
|
+
else if (args[i] === '--plan') flags.plan = path.resolve(args[++i])
|
|
1183
|
+
else if (args[i] === '--via') flags.via = args[++i]
|
|
1184
|
+
else if (args[i] === '--all') flags.all = args[++i]
|
|
1185
|
+
else if (args[i] === '--project') flags.project = args[++i]
|
|
525
1186
|
else if (args[i] === '--workspace-states') flags.workspaceStates = path.resolve(args[++i])
|
|
526
1187
|
else if (args[i] === '--skip-state-check') flags.skipStateCheck = true
|
|
527
1188
|
else if (args[i] === '--issue') flags.issue = args[++i]
|
|
528
1189
|
else if (args[i] === '--url') flags.url = args[++i]
|
|
529
1190
|
else if (args[i] === '--sub') flags.subs.push(args[++i])
|
|
1191
|
+
else if (args[i] === '--force') flags.force = true
|
|
1192
|
+
else if (args[i] === '--team-id') flags.teamId = String(args[++i] || '').trim()
|
|
1193
|
+
else if (args[i] === '--team-key') flags.teamKey = String(args[++i] || '').trim()
|
|
1194
|
+
else if (args[i] === '--project-id') flags.projectId = String(args[++i] || '').trim()
|
|
1195
|
+
else if (args[i] === '--intake-label') flags.intakeLabel = String(args[++i] || '').trim()
|
|
1196
|
+
else if (args[i] === '--bug-labels') flags.bugLabels = labelList(args[++i])
|
|
1197
|
+
else if (args[i] === '--hotfix-labels') flags.hotfixLabels = labelList(args[++i])
|
|
1198
|
+
else if (args[i] === '--state') {
|
|
1199
|
+
// `--state complete=Shipped`, repeatable. Only the buckets a workspace
|
|
1200
|
+
// actually renamed get written; the rest keep the defaults.
|
|
1201
|
+
const [bucket, ...rest] = String(args[++i] || '').split('=')
|
|
1202
|
+
flags.stateNames[String(bucket).trim()] = rest.join('=').trim()
|
|
1203
|
+
} else if (args[i] === '--states') flags.statesFile = path.resolve(args[++i])
|
|
530
1204
|
else positional.push(args[i])
|
|
531
1205
|
}
|
|
532
1206
|
dir = path.resolve(dir)
|
|
1207
|
+
// Injection seam, alongside cwd/out/err: `env` supplies the key lookup and
|
|
1208
|
+
// `adapter`/`fetch` stand in for the network, so `apply` is exercised end to
|
|
1209
|
+
// end offline. Production passes none of them and gets the real thing.
|
|
1210
|
+
flags.env = io.env || process.env
|
|
1211
|
+
if (io.adapter) flags.adapter = io.adapter
|
|
1212
|
+
if (io.fetch) flags.fetch = io.fetch
|
|
1213
|
+
|
|
1214
|
+
// Dispatched ahead of the load on purpose: this is the command you run when
|
|
1215
|
+
// there is no config, and `--force` must be able to replace one that is
|
|
1216
|
+
// malformed enough for the loader to throw on.
|
|
1217
|
+
if (sub === 'init-config') return specSyncInitConfig(dir, flags, out)
|
|
533
1218
|
|
|
534
1219
|
const { config, present } = loadLinearConfig(dir)
|
|
535
1220
|
if (!present) {
|
|
@@ -553,6 +1238,12 @@ async function specSync(rest, io = {}) {
|
|
|
553
1238
|
return 0
|
|
554
1239
|
case 'status':
|
|
555
1240
|
return specSyncStatus(dir, config, positional[0], flags, out) || 0
|
|
1241
|
+
case 'projects':
|
|
1242
|
+
return (await specSyncProjects(dir, config, flags, out)) || 0
|
|
1243
|
+
case 'states':
|
|
1244
|
+
return (await specSyncStates(dir, config, flags, out)) || 0
|
|
1245
|
+
case 'apply':
|
|
1246
|
+
return (await specSyncApply(dir, config, positional[0], flags, out)) || 0
|
|
556
1247
|
case 'verify':
|
|
557
1248
|
return specSyncVerify(dir, config, positional[0], flags, out) || 0
|
|
558
1249
|
case 'linked':
|
|
@@ -562,8 +1253,15 @@ async function specSync(rest, io = {}) {
|
|
|
562
1253
|
out.write('Usage: skitterspec spec-sync <normalize|record|status> <spec> [--json] [--remote file] [--workspace-states file]\n' +
|
|
563
1254
|
' skitterspec spec-sync push <spec> --workspace-states <file> [--json] [--skip-state-check]\n' +
|
|
564
1255
|
' skitterspec spec-sync stamp <spec> --issue KEY-1 [--url URL] [--sub <ref>=KEY-2 …]\n' +
|
|
1256
|
+
' skitterspec spec-sync states [--via api|mcp] [--json]\n' +
|
|
1257
|
+
' skitterspec spec-sync projects [--via api|mcp] [--json]\n' +
|
|
1258
|
+
' skitterspec spec-sync apply <spec> --plan <file> [--via api|mcp] [--project id] [--json]\n' +
|
|
1259
|
+
' skitterspec spec-sync apply --all <bucket> [--via api|mcp] [--json]\n' +
|
|
565
1260
|
' skitterspec spec-sync verify <spec> --stored <file>\n' +
|
|
566
|
-
' skitterspec spec-sync linked [--json]\n'
|
|
1261
|
+
' skitterspec spec-sync linked [--json]\n' +
|
|
1262
|
+
' skitterspec spec-sync init-config --team-id <id> [--team-key K] [--project-id id]\n' +
|
|
1263
|
+
' [--intake-label L] [--bug-labels a,b] [--hotfix-labels a,b]\n' +
|
|
1264
|
+
' [--state <bucket>=<name> …] [--states <file>] [--force] [--json]\n')
|
|
567
1265
|
return 0
|
|
568
1266
|
}
|
|
569
1267
|
}
|