@skitterbyte/skitterspec-linear 10.4.0 → 10.5.2
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/SETUP.md +13 -1
- package/assets/core/linear.config.md +43 -1
- package/assets/rules/negative-checks.md +69 -0
- package/assets/skills/spec-linear-setup/SKILL.md +48 -10
- package/assets/skills/spec-status/SKILL.md +18 -0
- package/assets/skills/spec-sync/SKILL.md +25 -35
- package/package.json +1 -1
- package/src/init.js +5 -0
- package/src/vendor/linear/api.js +31 -1
- package/src/vendor/linear/cli-sync.js +476 -111
- package/src/vendor/linear/doctor.js +244 -296
- package/src/vendor/sync-core/index.js +6 -0
- package/src/vendor/sync-core/src/normalize.js +17 -2
- package/src/vendor/sync-core/src/retarget.js +274 -0
- package/src/vendor/sync-core/src/verify.js +5 -0
|
@@ -42,11 +42,16 @@ const {
|
|
|
42
42
|
stampSubIssueId,
|
|
43
43
|
listPhaseFiles,
|
|
44
44
|
compareStored,
|
|
45
|
+
planRetarget,
|
|
46
|
+
applyRetarget,
|
|
47
|
+
deriveRecordedKey,
|
|
48
|
+
isEmptyRetarget,
|
|
49
|
+
dirtyPaths,
|
|
45
50
|
} = require('../sync-core')
|
|
46
51
|
|
|
47
52
|
const { loadLinearConfig, mergeConfig, defaults: configDefaults, CONFIG_FILE, LIFECYCLE_BUCKETS } = require('./config.js')
|
|
48
53
|
const { resolveApiKey, makeApiAdapter, stateIdFor, fetchWorkspaceStates } = require('./api.js')
|
|
49
|
-
const {
|
|
54
|
+
const { runChecks } = require('./doctor.js')
|
|
50
55
|
const {
|
|
51
56
|
storePath,
|
|
52
57
|
storeMode,
|
|
@@ -580,157 +585,449 @@ function verifyLines(snapshotDir, config, stored, identifier) {
|
|
|
580
585
|
}
|
|
581
586
|
|
|
582
587
|
/**
|
|
583
|
-
* `spec-sync doctor [--json]` —
|
|
588
|
+
* `spec-sync doctor [--json]` — is this project set up, across every layer?
|
|
584
589
|
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
* could not look (no key, MCP, unreadable team).
|
|
590
|
+
* The scaffold, isolation, the tracker config and the key are each checked by a
|
|
591
|
+
* different command or by none, so nothing answered the whole question. This
|
|
592
|
+
* does, and every failing row names the command that fixes it.
|
|
589
593
|
*
|
|
590
|
-
*
|
|
594
|
+
* The report itself is `doctor.js`, which is pure — this half only GATHERS. It
|
|
595
|
+
* is the one place allowed to touch the filesystem for that, and it must not
|
|
596
|
+
* throw doing it: a doctor that dies on a malformed config is useless on exactly
|
|
597
|
+
* the repo that needs it. So every read is wrapped, and a parse failure becomes
|
|
598
|
+
* a `broken` row rather than a stack trace.
|
|
591
599
|
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
* excludes archived issues AND caps at 250 per page, so the naive version
|
|
597
|
-
* reports most of a healthy repo as missing — measured on a real workspace:
|
|
598
|
-
* 328 issues, 149 archived, and the unpaginated default returns 179. Reading
|
|
599
|
-
* each ref individually has no list to page and no archived flag to forget;
|
|
600
|
-
* `issue(id:)` resolves an archived issue by identifier (also verified).
|
|
600
|
+
* Dispatched BEFORE the config load for the same reason (like `init-config`):
|
|
601
|
+
* the loader throws on malformed JSON and the dispatcher short-circuits when no
|
|
602
|
+
* config exists, so a doctor sitting after it could never report the two states
|
|
603
|
+
* it most needs to.
|
|
601
604
|
*/
|
|
602
|
-
async function specSyncDoctor(dir,
|
|
605
|
+
async function specSyncDoctor(dir, flags, out) {
|
|
606
|
+
const state = gatherState(dir, flags)
|
|
607
|
+
if (flags.mcp) {
|
|
608
|
+
const read = readMcpFacts(flags.mcp)
|
|
609
|
+
if (read.error) {
|
|
610
|
+
out.write(`spec-sync doctor: cannot read --mcp ${flags.mcp}: ${read.error}\n`)
|
|
611
|
+
return 1
|
|
612
|
+
}
|
|
613
|
+
state.mcp = read.facts
|
|
614
|
+
}
|
|
615
|
+
if (flags.remoteCheck) state.remote = await checkRemote(state, flags)
|
|
616
|
+
|
|
617
|
+
const report = runChecks(state)
|
|
618
|
+
|
|
619
|
+
if (flags.json) {
|
|
620
|
+
out.write(JSON.stringify(report, null, 2) + '\n')
|
|
621
|
+
return report.ok ? 0 : 1
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const width = Math.max(...report.checks.map((c) => c.label.length))
|
|
625
|
+
const lines = ['skitterspec doctor:']
|
|
626
|
+
for (const c of report.checks) {
|
|
627
|
+
lines.push(` ${c.label.padEnd(width)} ${c.state.padEnd(8)} ${c.detail}`)
|
|
628
|
+
// The fix sits under the row it belongs to, indented past the state column,
|
|
629
|
+
// so a wall of rows still reads as "this one, and here is the command".
|
|
630
|
+
if (c.fix) lines.push(` ${' '.repeat(width)} ${' '.repeat(8)} → ${c.fix}`)
|
|
631
|
+
}
|
|
632
|
+
// Everything not `ok`/`skipped` needs a human's attention — including a
|
|
633
|
+
// `missing` one, which is why the count and the EXIT CODE differ: a declined
|
|
634
|
+
// opt-in is worth reporting but must not fail the run (see `runChecks`).
|
|
635
|
+
const attention = report.checks.filter((c) => c.state === 'broken' || c.state === 'missing').length
|
|
636
|
+
lines.push('')
|
|
637
|
+
lines.push(attention ? ` ${attention} check(s) need attention.` : ' ready.')
|
|
638
|
+
|
|
639
|
+
out.write(lines.join('\n') + '\n')
|
|
640
|
+
return report.ok ? 0 : 1
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* The live half of the report: one `team(id:)` call proving the id resolves and
|
|
645
|
+
* the key is accepted. Well-formed config is not working config.
|
|
646
|
+
*
|
|
647
|
+
* Opt-in because it is the only part that needs the network — the offline checks
|
|
648
|
+
* have to stay usable with no connectivity, which is exactly when a setup
|
|
649
|
+
* problem is most annoying to diagnose.
|
|
650
|
+
*
|
|
651
|
+
* **No API message is ever relayed.** A GraphQL error body can echo the request
|
|
652
|
+
* back, and this is a command a skill prints; so a failure is CLASSIFIED into a
|
|
653
|
+
* short reason of our own words.
|
|
654
|
+
*/
|
|
655
|
+
async function checkRemote(state, flags) {
|
|
656
|
+
if (!state.tracker.parsed || !state.tracker.teamId) {
|
|
657
|
+
return { checked: true, skipped: true, reason: 'no usable tracker config to check against' }
|
|
658
|
+
}
|
|
659
|
+
if (!state.key.ok) {
|
|
660
|
+
return { checked: true, skipped: true, reason: 'no key, so there is nothing to check with' }
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const key = resolveApiKey(state._config, flags.env || process.env)
|
|
664
|
+
const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
|
|
665
|
+
let team
|
|
666
|
+
try {
|
|
667
|
+
team = await adapter.readTeam(state.tracker.teamId)
|
|
668
|
+
} catch (error) {
|
|
669
|
+
const failure = classifyRemoteFailure(error)
|
|
670
|
+
// NEVER ANSWERED is not the same as ANSWERED NO. An unreachable API or a
|
|
671
|
+
// rate-limit means the check did not run — the setup is unexamined, not
|
|
672
|
+
// wrong — so it reports `skipped`, the same state as "you didn't ask for
|
|
673
|
+
// it". Calling it `broken` exited 1 on a healthy project that merely had no
|
|
674
|
+
// network, and every skill branching on that code failed with it.
|
|
675
|
+
if (failure.reached === false) return { checked: true, skipped: true, reason: failure.reason }
|
|
676
|
+
return { checked: true, ok: false, ...failure }
|
|
677
|
+
}
|
|
678
|
+
if (!team || !team.key) {
|
|
679
|
+
return {
|
|
680
|
+
checked: true,
|
|
681
|
+
ok: false,
|
|
682
|
+
reason: 'the key was accepted, but no team has that id',
|
|
683
|
+
fix: '/spec-linear-setup',
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
// The team resolved, so the key works. Only now is it worth spending further
|
|
687
|
+
// calls — on the workspace this key belongs to (so the MCP row has an API side
|
|
688
|
+
// to compare against, and only when there is an MCP side to compare with), and
|
|
689
|
+
// on the project, when one is configured.
|
|
690
|
+
let organization = null
|
|
691
|
+
if (state.mcp && state.mcp.workspace && typeof adapter.readOrganization === 'function') {
|
|
692
|
+
try {
|
|
693
|
+
organization = await adapter.readOrganization()
|
|
694
|
+
} catch {
|
|
695
|
+
// Unexamined, exactly like the project below: the team read already proved
|
|
696
|
+
// the key works, so a failure here is not evidence about the config.
|
|
697
|
+
organization = null
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
let project = null
|
|
703
|
+
// `readProject` is API-only. An adapter without it cannot answer the question,
|
|
704
|
+
// and a `TypeError` caught below would be dressed up as Linear refusing the
|
|
705
|
+
// request — accusing the user's config of a gap that is ours. Unexamined.
|
|
706
|
+
if (state.project && state.project.configured && typeof adapter.readProject === 'function') {
|
|
707
|
+
try {
|
|
708
|
+
const found = await adapter.readProject(state.project.configured)
|
|
709
|
+
project = found
|
|
710
|
+
? {
|
|
711
|
+
resolved: true,
|
|
712
|
+
name: found.name,
|
|
713
|
+
// Membership, not equality: a Linear project can span teams.
|
|
714
|
+
belongsToTeam: (found.teams || []).some((t) => t && t.id === state.tracker.teamId),
|
|
715
|
+
}
|
|
716
|
+
: { resolved: false }
|
|
717
|
+
} catch (error) {
|
|
718
|
+
// Same rule as the team read: NEVER ANSWERED is not ANSWERED NO. A
|
|
719
|
+
// transport failure leaves the project unexamined rather than accused.
|
|
720
|
+
const failure = classifyRemoteFailure(error)
|
|
721
|
+
project = failure.reached === false ? null : { resolved: false, reason: failure.reason }
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
return { checked: true, ok: true, teamKey: team.key, teamId: team.id, organization, recordedKey: state.tracker.teamKey, project }
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* The MCP server's view of where this repo files, as the calling skill read it.
|
|
730
|
+
*
|
|
731
|
+
* The engine never speaks MCP — the same split `--workspace-states` and
|
|
732
|
+
* `verify --stored` use — so a skill fetches `get_workspace` / `get_team` /
|
|
733
|
+
* `get_project` and writes them here:
|
|
734
|
+
*
|
|
735
|
+
* { "workspace": {"id","name"}, "team": {"id","key"}, "project": {"id","name"} }
|
|
736
|
+
*
|
|
737
|
+
* Every field is optional. A field the skill could not fetch is ABSENT, and
|
|
738
|
+
* absent means unchecked — never mismatched. That distinction is the whole
|
|
739
|
+
* safety of the row: it is a comparison, so it can only speak about the pairs it
|
|
740
|
+
* actually has both halves of.
|
|
741
|
+
*/
|
|
742
|
+
function readMcpFacts(file) {
|
|
743
|
+
let parsed
|
|
744
|
+
try {
|
|
745
|
+
parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
746
|
+
} catch (error) {
|
|
747
|
+
return { error: error.message }
|
|
748
|
+
}
|
|
749
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
750
|
+
return { error: 'expected an object with workspace / team / project keys' }
|
|
751
|
+
}
|
|
752
|
+
const pick = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null)
|
|
753
|
+
return { facts: { workspace: pick(parsed.workspace), team: pick(parsed.team), project: pick(parsed.project) } }
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Map a thrown API error onto our own short reason. Matched on the shapes
|
|
757
|
+
// `api.js` raises; anything unrecognised degrades to a generic line rather than
|
|
758
|
+
// leaking the message.
|
|
759
|
+
//
|
|
760
|
+
// BLIND SPOT: matching on message text, so an api.js rewording lands here as the
|
|
761
|
+
// unrecognised case. That is why the fallback stays `broken` — Linear ANSWERED
|
|
762
|
+
// and refused, which is evidence of a problem even when we cannot name it. Only
|
|
763
|
+
// a request that got no answer at all (`reached: false`) is un-evidence.
|
|
764
|
+
function classifyRemoteFailure(error) {
|
|
765
|
+
const m = String((error && error.message) || '')
|
|
766
|
+
if (/rejected the API key|HTTP 401|HTTP 403/.test(m)) {
|
|
767
|
+
return { reason: 'Linear rejected the key — it may be revoked or for another workspace', fix: 'skitterspec spec-sync credentials set' }
|
|
768
|
+
}
|
|
769
|
+
// `reached: false` — the request never got an answer, so it says nothing about
|
|
770
|
+
// whether this project is set up correctly. See the caller.
|
|
771
|
+
if (/unreachable/.test(m)) {
|
|
772
|
+
return { reason: 'Linear could not be reached — check your connection', fix: null, reached: false }
|
|
773
|
+
}
|
|
774
|
+
if (/rate-limited/.test(m)) {
|
|
775
|
+
return { reason: 'Linear rate-limited the request and did not recover', fix: null, reached: false }
|
|
776
|
+
}
|
|
777
|
+
if (/Entity not found|not found/i.test(m)) {
|
|
778
|
+
return { reason: 'no team with that id in this workspace', fix: '/spec-linear-setup' }
|
|
779
|
+
}
|
|
780
|
+
return { reason: 'Linear did not accept the request', fix: null }
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Read the project's real state for `runChecks`. Never throws: every probe that
|
|
784
|
+
// can fail reports the failure as data.
|
|
785
|
+
function gatherState(dir, flags) {
|
|
786
|
+
const state = { scaffold: {}, isolation: {}, tracker: {}, key: {}, project: {}, remote: { checked: false } }
|
|
787
|
+
|
|
788
|
+
const specs = path.join(dir, 'specs')
|
|
789
|
+
state.scaffold.specsDir = fs.existsSync(specs)
|
|
790
|
+
if (state.scaffold.specsDir) {
|
|
791
|
+
state.scaffold.core = fs.existsSync(path.join(specs, '.core'))
|
|
792
|
+
// Reported for context only — a missing bucket is normal (see scaffoldCheck).
|
|
793
|
+
state.scaffold.buckets = BUCKETS.filter((b) => fs.existsSync(path.join(specs, b)))
|
|
794
|
+
state.scaffold.skills = countSkills(dir)
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const envFile = path.join(dir, 'specs', '.core', 'env.config.json')
|
|
798
|
+
state.isolation.present = fs.existsSync(envFile)
|
|
799
|
+
if (state.isolation.present) {
|
|
800
|
+
try {
|
|
801
|
+
JSON.parse(fs.readFileSync(envFile, 'utf-8'))
|
|
802
|
+
state.isolation.parsed = true
|
|
803
|
+
} catch (error) {
|
|
804
|
+
state.isolation.parsed = false
|
|
805
|
+
state.isolation.error = error.message
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
state.tracker.present = fs.existsSync(path.join(dir, CONFIG_FILE))
|
|
810
|
+
let config = null
|
|
811
|
+
if (state.tracker.present) {
|
|
812
|
+
try {
|
|
813
|
+
config = loadLinearConfig(dir).config
|
|
814
|
+
state.tracker.parsed = true
|
|
815
|
+
state.tracker.teamId = (config.linear && config.linear.teamId) || ''
|
|
816
|
+
state.tracker.teamKey = (config.linear && config.linear.teamKey) || ''
|
|
817
|
+
// Offline this is all that can be known: whether a string is there. That a
|
|
818
|
+
// well-formed id names a LIVE project is only answerable with
|
|
819
|
+
// --check-remote, which is why the row says what it checked.
|
|
820
|
+
state.project.configured = (config.linear && config.linear.projectId) || ''
|
|
821
|
+
} catch (error) {
|
|
822
|
+
state.tracker.parsed = false
|
|
823
|
+
state.tracker.error = error.message
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Without a parsed config there is no `auth.keyEnv` and no team to key on, so
|
|
828
|
+
// there is nothing to look up — the key row reports as skipped instead.
|
|
829
|
+
if (config) {
|
|
830
|
+
const resolved = resolveApiKey(config, flags.env || process.env)
|
|
831
|
+
// A key resolves from the environment, the store, or a `keyCommand` the
|
|
832
|
+
// store runs — `resolveApiKey` covers all three, so the row must not be read
|
|
833
|
+
// as "env var unset".
|
|
834
|
+
//
|
|
835
|
+
// When it does NOT resolve, `resolveApiKey` appends the reason on later
|
|
836
|
+
// lines: a store that is world-readable, or a keyCommand that failed.
|
|
837
|
+
// Dropping it reported a broken command as `no key for SKS` and sent the
|
|
838
|
+
// user to set a key they had already set (`credentials status` keeps it for
|
|
839
|
+
// the same reason).
|
|
840
|
+
const why = resolved.ok ? '' : resolved.error.split('\n').slice(1).map((l) => l.trim()).filter(Boolean).join('; ')
|
|
841
|
+
state.key = resolved.ok
|
|
842
|
+
? { ok: true, source: resolved.source === 'env' ? `the environment (${resolved.envVar})` : resolved.source, fingerprint: fingerprint(resolved.key) }
|
|
843
|
+
: { ok: false, error: `no key for ${state.tracker.teamKey || state.tracker.teamId}${why ? ` — ${why}` : ''}` }
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
state._config = config
|
|
847
|
+
return state
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// How many skills are installed in the target project.
|
|
851
|
+
function countSkills(dir) {
|
|
852
|
+
const skills = path.join(dir, '.claude', 'skills')
|
|
853
|
+
try {
|
|
854
|
+
return fs
|
|
855
|
+
.readdirSync(skills, { withFileTypes: true })
|
|
856
|
+
.filter((e) => (e.isDirectory() || e.isSymbolicLink()) && fs.existsSync(path.join(skills, e.name, 'SKILL.md')))
|
|
857
|
+
.length
|
|
858
|
+
} catch {
|
|
859
|
+
return 0
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* `spec-sync retarget [--yes]` — repoint a mirror after a team-key rename.
|
|
865
|
+
*
|
|
866
|
+
* Renaming a Linear team rewrites the key in every issue identifier, and the
|
|
867
|
+
* repo stamps those in three places. Nothing moved them, so afterwards
|
|
868
|
+
* `/spec-push` fails with `no Linear issue found for SKI-7` — the right failure,
|
|
869
|
+
* with no way out but a hand rewrite (done twice by hand on 2026-09-02).
|
|
870
|
+
*
|
|
871
|
+
* The rewrite itself is provider-neutral and lives in `sync-core/retarget.js`.
|
|
872
|
+
* Only two things here touch Linear:
|
|
873
|
+
*
|
|
874
|
+
* 1. **Detection.** `teamId` survives a rename, so ask Linear for that team's
|
|
875
|
+
* CURRENT key and compare it with the recorded one. A difference IS the
|
|
876
|
+
* rename. It is never taken as an argument: nothing stops a typo rewriting
|
|
877
|
+
* every stamp to a key that does not exist.
|
|
878
|
+
* 2. **One spot-check.** Resolve the first remapped identifier and compare its
|
|
879
|
+
* TITLE to the spec's. That is what makes the number-preservation assumption
|
|
880
|
+
* safe, and it tests identity rather than mere existence — `SKS-7` existing
|
|
881
|
+
* does not make it the issue that was `SKI-7`. One read, which is also what
|
|
882
|
+
* keeps the MCP path viable.
|
|
883
|
+
*/
|
|
884
|
+
async function specSyncRetarget(dir, config, flags, out) {
|
|
885
|
+
const teamId = (config.linear && config.linear.teamId) || ''
|
|
886
|
+
if (!teamId) {
|
|
887
|
+
out.write('spec-sync retarget: no linear.teamId in specs/.core/linear.config.json — nothing to compare against.\n')
|
|
888
|
+
return 1
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const recorded = deriveRecordedKey(dir, config)
|
|
892
|
+
if (!recorded.key) {
|
|
893
|
+
out.write(`spec-sync retarget: cannot tell which key this repo is stamped with.\n ${recorded.reason}\n`)
|
|
894
|
+
return 1
|
|
895
|
+
}
|
|
896
|
+
|
|
603
897
|
const key = resolveApiKey(config, flags.env || process.env)
|
|
604
898
|
const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
|
|
605
899
|
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
// call `apply --all` makes.
|
|
900
|
+
// Linear's MCP `get_team` does not return the team key (observed 2026-09-02),
|
|
901
|
+
// so the one fact detection needs is unavailable there. Report what IS known
|
|
902
|
+
// and let the operator supply it, rather than guessing or silently skipping.
|
|
610
903
|
if (transport === 'mcp') {
|
|
611
904
|
out.write(
|
|
612
905
|
[
|
|
613
|
-
'spec-sync
|
|
906
|
+
'spec-sync retarget: transport = mcp — cannot read the team key (nothing was changed)',
|
|
614
907
|
` ${key.ok ? '--via mcp was requested' : key.error}`,
|
|
615
|
-
|
|
908
|
+
` recorded key: ${recorded.key} (from ${recorded.source})`,
|
|
909
|
+
" Linear's MCP get_team does not return a team key, so a rename cannot be detected here.",
|
|
910
|
+
' Confirm the current key in Linear, then set an API key and re-run for the plan.',
|
|
616
911
|
].join('\n') + '\n',
|
|
617
912
|
)
|
|
618
913
|
return 1
|
|
619
914
|
}
|
|
620
915
|
|
|
621
|
-
const teamId = (config.linear && config.linear.teamId) || ''
|
|
622
|
-
if (!teamId) {
|
|
623
|
-
out.write('spec-sync doctor: no linear.teamId in specs/.core/linear.config.json — nothing to compare against.\n')
|
|
624
|
-
return 1
|
|
625
|
-
}
|
|
626
|
-
|
|
627
916
|
const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
|
|
628
917
|
let team
|
|
629
918
|
try {
|
|
630
919
|
team = await adapter.readTeam(teamId)
|
|
631
920
|
} catch (error) {
|
|
632
|
-
out.write(`spec-sync
|
|
921
|
+
out.write(`spec-sync retarget: could not read the team: ${error.message}\n`)
|
|
633
922
|
return 1
|
|
634
923
|
}
|
|
635
924
|
if (!team || !team.key) {
|
|
636
|
-
out.write(`spec-sync
|
|
925
|
+
out.write(`spec-sync retarget: Linear returned no team for ${teamId}\n`)
|
|
637
926
|
return 1
|
|
638
927
|
}
|
|
639
928
|
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
for (const ref of drift.refs) {
|
|
646
|
-
let got
|
|
647
|
-
try {
|
|
648
|
-
got = await adapter.readIssue(ref.to)
|
|
649
|
-
} catch (error) {
|
|
650
|
-
out.write(`spec-sync doctor: could not read ${ref.to}: ${error.message}\n`)
|
|
651
|
-
return 1
|
|
652
|
-
}
|
|
653
|
-
if (!got) missing.push(ref)
|
|
654
|
-
}
|
|
929
|
+
const header = [
|
|
930
|
+
`spec-sync retarget: team ${teamId} (${team.name || team.key})`,
|
|
931
|
+
` recorded key: ${recorded.key} (from ${recorded.source})`,
|
|
932
|
+
` linear key: ${team.key}${team.key === recorded.key ? '' : ' <- renamed'}`,
|
|
933
|
+
]
|
|
655
934
|
|
|
656
|
-
if (
|
|
657
|
-
out.write(
|
|
935
|
+
if (team.key === recorded.key) {
|
|
936
|
+
out.write([...header, '', ' already current — nothing to retarget'].join('\n') + '\n')
|
|
658
937
|
return 0
|
|
659
938
|
}
|
|
660
939
|
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
lines.push(' drift: none — every stamped identifier is on the current team key')
|
|
665
|
-
out.write(lines.join('\n') + '\n')
|
|
940
|
+
const plan = planRetarget({ dir, oldKey: recorded.key, newKey: team.key, config })
|
|
941
|
+
if (isEmptyRetarget(plan)) {
|
|
942
|
+
out.write([...header, '', ' the key moved, but nothing in the repo is stamped with it'].join('\n') + '\n')
|
|
666
943
|
return 0
|
|
667
944
|
}
|
|
668
945
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
lines.push(
|
|
688
|
-
` mentions: ${drift.mentions.length} stale ref(s) in spec prose — reported, NOT repaired by --write`,
|
|
689
|
-
)
|
|
946
|
+
// Spot-check BEFORE reporting the plan as safe: a wrong mapping is caught
|
|
947
|
+
// here, not after the files have moved.
|
|
948
|
+
const check = await spotCheck({ dir, config, plan, adapter, oldKey: recorded.key, newKey: team.key })
|
|
949
|
+
const lines = [
|
|
950
|
+
...header,
|
|
951
|
+
'',
|
|
952
|
+
' would rewrite:',
|
|
953
|
+
` ${plan.stamps.length} frontmatter stamp(s) (linear_identifier, linear_url, linear_issue_id)`,
|
|
954
|
+
` ${plan.snapshots.length} base snapshot(s) (rename + re-key subIssues)`,
|
|
955
|
+
` ${plan.configKey ? 1 : 0} config key (linear.teamKey)`,
|
|
956
|
+
'',
|
|
957
|
+
` spot-check: ${check.line}`,
|
|
958
|
+
]
|
|
959
|
+
|
|
960
|
+
if (!check.ok) {
|
|
961
|
+
lines.push(' refusing — the mapping is not safe to apply')
|
|
962
|
+
out.write(lines.join('\n') + '\n')
|
|
963
|
+
return 1
|
|
690
964
|
}
|
|
691
|
-
lines.push(` missing: ${missing.length} ref(s) that resolve to no issue under ${team.key}`)
|
|
692
|
-
for (const m of missing.slice(0, 10)) lines.push(` ${m.from} → ${m.to} does not exist`)
|
|
693
|
-
if (missing.length > 10) lines.push(` … and ${missing.length - 10} more`)
|
|
694
965
|
|
|
695
|
-
if (!flags.
|
|
696
|
-
lines.push(' run with --
|
|
966
|
+
if (!flags.yes) {
|
|
967
|
+
lines.push(' dry-run — re-run with --yes to apply.')
|
|
697
968
|
out.write(lines.join('\n') + '\n')
|
|
698
969
|
return 0
|
|
699
970
|
}
|
|
700
971
|
|
|
701
|
-
// A repair rewrites hundreds of stamps across dozens of files. That is only
|
|
702
|
-
// safe to hand someone if it arrives as ONE reviewable diff they can throw
|
|
703
|
-
// away with `git checkout -- .` — which a dirty tree destroys. Same guard
|
|
704
|
-
// `spec-env integrate` uses, and for the same reason.
|
|
705
972
|
const dirty = dirtyPaths(dir)
|
|
706
973
|
if (dirty === null) {
|
|
707
|
-
lines.push(' --
|
|
974
|
+
lines.push(' --yes refused: not a git repository, so the rewrite would not be reviewable')
|
|
708
975
|
out.write(lines.join('\n') + '\n')
|
|
709
976
|
return 1
|
|
710
977
|
}
|
|
711
978
|
if (dirty.length) {
|
|
712
|
-
lines.push(` --
|
|
979
|
+
lines.push(` --yes refused: ${dirty.length} uncommitted change(s) — commit or stash first`)
|
|
713
980
|
for (const d of dirty.slice(0, 10)) lines.push(` ${d}`)
|
|
714
981
|
if (dirty.length > 10) lines.push(` … and ${dirty.length - 10} more`)
|
|
715
|
-
lines.push(' the
|
|
982
|
+
lines.push(' the rewrite must land as one reviewable, revertable change')
|
|
716
983
|
out.write(lines.join('\n') + '\n')
|
|
717
984
|
return 1
|
|
718
985
|
}
|
|
719
986
|
|
|
720
|
-
const
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
lines.push(' review the diff, then commit it')
|
|
987
|
+
const changed = applyRetarget(plan, { dir, config })
|
|
988
|
+
lines.push(
|
|
989
|
+
' applied:',
|
|
990
|
+
` ${changed.files.length} spec file(s)`,
|
|
991
|
+
` ${changed.snapshots.length} snapshot(s) moved`,
|
|
992
|
+
...(changed.configKey ? [' config linear.teamKey'] : []),
|
|
993
|
+
" nothing was pushed — only the repo's stamps moved; the mirror is untouched.",
|
|
994
|
+
' review the diff, then commit it.',
|
|
995
|
+
)
|
|
730
996
|
out.write(lines.join('\n') + '\n')
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
997
|
+
return 0
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Resolve the first remapped SPEC issue and compare its title to the spec's.
|
|
1002
|
+
*
|
|
1003
|
+
* Deliberately ONE read. Checking every ref instead would cost a request each
|
|
1004
|
+
* (~198 on a real repo), force an MCP refusal, and still only prove the issues
|
|
1005
|
+
* exist — not that they are the same issues.
|
|
1006
|
+
*/
|
|
1007
|
+
async function spotCheck({ dir, config, plan, adapter, oldKey, newKey }) {
|
|
1008
|
+
const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
|
|
1009
|
+
const stamp = plan.stamps.find((s) => path.basename(s.file) === overviewFile)
|
|
1010
|
+
if (!stamp) return { ok: true, line: 'skipped — no spec issue in the plan to check' }
|
|
1011
|
+
|
|
1012
|
+
const before = parseFrontmatter(stamp.from).data.linear_identifier
|
|
1013
|
+
if (!before) return { ok: true, line: 'skipped — no linear_identifier in the plan to check' }
|
|
1014
|
+
const from = String(before).trim()
|
|
1015
|
+
const to = `${newKey}-${from.slice(oldKey.length + 1)}`
|
|
1016
|
+
|
|
1017
|
+
let issue
|
|
1018
|
+
try {
|
|
1019
|
+
issue = await adapter.readIssue(to)
|
|
1020
|
+
} catch (error) {
|
|
1021
|
+
return { ok: false, line: `${to} could not be read — ${error.message}` }
|
|
1022
|
+
}
|
|
1023
|
+
if (!issue) return { ok: false, line: `${from} → ${to}, but ${to} does not exist in Linear` }
|
|
1024
|
+
|
|
1025
|
+
const expected = readSnapshot(path.dirname(path.join(dir, stamp.file)), config).title
|
|
1026
|
+
const got = issue.title || ''
|
|
1027
|
+
if (expected && got && expected.trim() !== got.trim()) {
|
|
1028
|
+
return { ok: false, line: `${to} resolves, but its title is "${got}" — the spec's is "${expected}"` }
|
|
1029
|
+
}
|
|
1030
|
+
return { ok: true, line: `${to} resolves, title matches the spec` }
|
|
734
1031
|
}
|
|
735
1032
|
|
|
736
1033
|
/**
|
|
@@ -1488,7 +1785,19 @@ async function credentialsSet(file, teamId, label, flags, out) {
|
|
|
1488
1785
|
|
|
1489
1786
|
let key
|
|
1490
1787
|
if (flags.stdin) {
|
|
1491
|
-
|
|
1788
|
+
const piped = flags.input || process.stdin
|
|
1789
|
+
// A TTY on stdin is positive evidence that nothing was piped: `--stdin` then
|
|
1790
|
+
// waits for an EOF a terminal never sends, printing nothing while it does.
|
|
1791
|
+
// Refuse and name the two working forms rather than blocking forever.
|
|
1792
|
+
if (piped.isTTY) {
|
|
1793
|
+
out.write(
|
|
1794
|
+
'spec-sync credentials: --stdin expects a pipe, but stdin is a terminal.\n' +
|
|
1795
|
+
' Run it without --stdin to be prompted (input is hidden), or pipe the key:\n' +
|
|
1796
|
+
' <command that prints the key> | skitterspec spec-sync credentials set --stdin\n',
|
|
1797
|
+
)
|
|
1798
|
+
return 1
|
|
1799
|
+
}
|
|
1800
|
+
key = (await readAllStdin(piped)).trim()
|
|
1492
1801
|
if (!key) {
|
|
1493
1802
|
out.write('spec-sync credentials: nothing on stdin — no key stored.\n')
|
|
1494
1803
|
return 1
|
|
@@ -1560,19 +1869,33 @@ function readAllStdin(input) {
|
|
|
1560
1869
|
}
|
|
1561
1870
|
|
|
1562
1871
|
// Prompt on a TTY with the input hidden. `_writeToOutput` is readline's own echo
|
|
1563
|
-
// hook —
|
|
1872
|
+
// hook — filtering it is what keeps the key off the screen (and out of a
|
|
1564
1873
|
// screen-shared terminal or a recorded session).
|
|
1874
|
+
//
|
|
1875
|
+
// READLINE OWNS THE PROMPT, deliberately. Writing it ourselves and then starting
|
|
1876
|
+
// the interface loses it: readline clears from the cursor to the end of the
|
|
1877
|
+
// screen (`ESC[0J`) before its first redraw, so the prompt was wiped the instant
|
|
1878
|
+
// it appeared and the user was left staring at a blank line while the process
|
|
1879
|
+
// waited for a key — indistinguishable from a hang.
|
|
1880
|
+
//
|
|
1881
|
+
// The hook is assigned BEFORE `question`, so the very first redraw goes through
|
|
1882
|
+
// it. readline hands it `prompt + what has been typed`; re-writing only the
|
|
1883
|
+
// prompt is what keeps the key hidden while the prompt survives every redraw.
|
|
1565
1884
|
function promptHidden(question, input, out) {
|
|
1566
1885
|
const readline = require('node:readline')
|
|
1567
1886
|
return new Promise((resolve) => {
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1887
|
+
// `out`, never `process.stdout`: they are the same stream in production, and
|
|
1888
|
+
// hardcoding one half meant readline cleared a screen the prompt had not
|
|
1889
|
+
// been written to under test — the split that hid this bug from the suite.
|
|
1890
|
+
const rl = readline.createInterface({ input, output: out, terminal: true })
|
|
1891
|
+
rl._writeToOutput = (s) => {
|
|
1892
|
+
if (s.includes(question)) out.write(question)
|
|
1893
|
+
}
|
|
1894
|
+
rl.question(question, (answer) => {
|
|
1571
1895
|
out.write('\n')
|
|
1572
1896
|
rl.close()
|
|
1573
1897
|
resolve(answer)
|
|
1574
1898
|
})
|
|
1575
|
-
rl._writeToOutput = () => {}
|
|
1576
1899
|
})
|
|
1577
1900
|
}
|
|
1578
1901
|
|
|
@@ -1582,13 +1905,18 @@ async function specSync(rest, io = {}) {
|
|
|
1582
1905
|
const [sub, ...args] = rest
|
|
1583
1906
|
let dir = io.cwd || process.cwd()
|
|
1584
1907
|
const positional = []
|
|
1908
|
+
// Anything `--`-prefixed that no branch below consumed. Collected rather than
|
|
1909
|
+
// pushed onto `positional`, where it was silently discarded — see the refusal
|
|
1910
|
+
// after the loop.
|
|
1911
|
+
const unknownFlags = []
|
|
1585
1912
|
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,
|
|
1586
|
-
force: false,
|
|
1913
|
+
mcp: null, force: false, yes: false, remoteCheck: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null }
|
|
1587
1914
|
for (let i = 0; i < args.length; i++) {
|
|
1588
1915
|
if (args[i] === '--dir') dir = path.resolve(args[++i])
|
|
1589
1916
|
else if (args[i] === '--json') flags.json = true
|
|
1590
1917
|
else if (args[i] === '--remote') flags.remote = path.resolve(args[++i])
|
|
1591
1918
|
else if (args[i] === '--stored') flags.stored = path.resolve(args[++i])
|
|
1919
|
+
else if (args[i] === '--mcp') flags.mcp = path.resolve(args[++i])
|
|
1592
1920
|
else if (args[i] === '--plan') flags.plan = path.resolve(args[++i])
|
|
1593
1921
|
else if (args[i] === '--via') flags.via = args[++i]
|
|
1594
1922
|
else if (args[i] === '--all') flags.all = args[++i]
|
|
@@ -1599,7 +1927,8 @@ async function specSync(rest, io = {}) {
|
|
|
1599
1927
|
else if (args[i] === '--url') flags.url = args[++i]
|
|
1600
1928
|
else if (args[i] === '--sub') flags.subs.push(args[++i])
|
|
1601
1929
|
else if (args[i] === '--force') flags.force = true
|
|
1602
|
-
else if (args[i] === '--
|
|
1930
|
+
else if (args[i] === '--yes') flags.yes = true
|
|
1931
|
+
else if (args[i] === '--check-remote') flags.remoteCheck = true
|
|
1603
1932
|
else if (args[i] === '--stdin') flags.stdin = true
|
|
1604
1933
|
else if (args[i] === '--command') flags.command = String(args[++i] || '').trim()
|
|
1605
1934
|
else if (args[i] === '--key') {
|
|
@@ -1622,8 +1951,34 @@ async function specSync(rest, io = {}) {
|
|
|
1622
1951
|
const [bucket, ...rest] = String(args[++i] || '').split('=')
|
|
1623
1952
|
flags.stateNames[String(bucket).trim()] = rest.join('=').trim()
|
|
1624
1953
|
} else if (args[i] === '--states') flags.statesFile = path.resolve(args[++i])
|
|
1954
|
+
else if (args[i].startsWith('--')) unknownFlags.push(args[i])
|
|
1625
1955
|
else positional.push(args[i])
|
|
1626
1956
|
}
|
|
1957
|
+
// REFUSE AN UNKNOWN FLAG, before anything runs.
|
|
1958
|
+
//
|
|
1959
|
+
// These used to land in `positional` and vanish. That is merely untidy for a
|
|
1960
|
+
// typo, but it turned a RENAMED flag into a silent no-op: `--write` moved to
|
|
1961
|
+
// `--yes` when `doctor` became `retarget`, so `spec-sync doctor --write` — the
|
|
1962
|
+
// exact 10.4.0 invocation for repairing a renamed team — parsed, ran the
|
|
1963
|
+
// readiness report instead, ignored the flag and exited 0. A script would read
|
|
1964
|
+
// that as "repaired".
|
|
1965
|
+
//
|
|
1966
|
+
// A spec name never starts with `--`, so this cannot swallow a real argument.
|
|
1967
|
+
if (unknownFlags.length) {
|
|
1968
|
+
const lines = [`spec-sync: unknown flag ${unknownFlags.join(', ')}`]
|
|
1969
|
+
// Renamed flags get a specific hand-off; a bare "unknown flag" would leave
|
|
1970
|
+
// the caller to guess what replaced it.
|
|
1971
|
+
if (unknownFlags.includes('--write')) {
|
|
1972
|
+
lines.push(
|
|
1973
|
+
' --write was replaced by --yes, and the command that repairs a renamed',
|
|
1974
|
+
' team is now `spec-sync retarget --yes` (it was `doctor --write`).',
|
|
1975
|
+
)
|
|
1976
|
+
}
|
|
1977
|
+
lines.push(' run `skitterspec spec-sync` for the full usage.')
|
|
1978
|
+
out.write(lines.join('\n') + '\n')
|
|
1979
|
+
return 1
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1627
1982
|
dir = path.resolve(dir)
|
|
1628
1983
|
// Injection seam, alongside cwd/out/err: `env` supplies the key lookup and
|
|
1629
1984
|
// `adapter`/`fetch` stand in for the network, so `apply` is exercised end to
|
|
@@ -1631,12 +1986,21 @@ async function specSync(rest, io = {}) {
|
|
|
1631
1986
|
flags.env = io.env || process.env
|
|
1632
1987
|
if (io.adapter) flags.adapter = io.adapter
|
|
1633
1988
|
if (io.fetch) flags.fetch = io.fetch
|
|
1989
|
+
// The stdin seam. `credentials set` branches on whether stdin is a TTY, and
|
|
1990
|
+
// with no way to inject one the suite could only ever exercise the non-TTY
|
|
1991
|
+
// half — which is how a prompt that erased itself reached a release.
|
|
1992
|
+
if (io.input) flags.input = io.input
|
|
1634
1993
|
|
|
1635
1994
|
// Dispatched ahead of the load on purpose: this is the command you run when
|
|
1636
1995
|
// there is no config, and `--force` must be able to replace one that is
|
|
1637
1996
|
// malformed enough for the loader to throw on.
|
|
1638
1997
|
if (sub === 'init-config') return specSyncInitConfig(dir, flags, out)
|
|
1639
1998
|
|
|
1999
|
+
// Same reason as init-config: this is the command you run when the config is
|
|
2000
|
+
// missing or malformed, so it must not sit behind a loader that throws on the
|
|
2001
|
+
// one and short-circuits on the other.
|
|
2002
|
+
if (sub === 'doctor') return await specSyncDoctor(dir, flags, out)
|
|
2003
|
+
|
|
1640
2004
|
const { config, present } = loadLinearConfig(dir)
|
|
1641
2005
|
if (!present) {
|
|
1642
2006
|
out.write(
|
|
@@ -1661,8 +2025,8 @@ async function specSync(rest, io = {}) {
|
|
|
1661
2025
|
return (await specSyncProjects(dir, config, flags, out)) || 0
|
|
1662
2026
|
case 'states':
|
|
1663
2027
|
return (await specSyncStates(dir, config, flags, out)) || 0
|
|
1664
|
-
case '
|
|
1665
|
-
return (await
|
|
2028
|
+
case 'retarget':
|
|
2029
|
+
return (await specSyncRetarget(dir, config, flags, out)) || 0
|
|
1666
2030
|
case 'apply':
|
|
1667
2031
|
return (await specSyncApply(dir, config, positional[0], flags, out)) || 0
|
|
1668
2032
|
case 'verify':
|
|
@@ -1683,7 +2047,8 @@ async function specSync(rest, io = {}) {
|
|
|
1683
2047
|
' skitterspec spec-sync apply --all <bucket> [--via api|mcp] [--json]\n' +
|
|
1684
2048
|
' skitterspec spec-sync verify <spec> --stored <file>\n' +
|
|
1685
2049
|
' skitterspec spec-sync linked [--json]\n' +
|
|
1686
|
-
' skitterspec spec-sync
|
|
2050
|
+
' skitterspec spec-sync retarget [--yes]\n' +
|
|
2051
|
+
' skitterspec spec-sync doctor [--check-remote] [--mcp <file>] [--json]\n' +
|
|
1687
2052
|
' skitterspec spec-sync init-config --team-id <id> [--team-key K] [--project-id id]\n' +
|
|
1688
2053
|
' [--intake-label L] [--bug-labels a,b] [--hotfix-labels a,b]\n' +
|
|
1689
2054
|
' [--state <bucket>=<name> …] [--states <file>] [--force] [--json]\n')
|
|
@@ -1691,4 +2056,4 @@ async function specSync(rest, io = {}) {
|
|
|
1691
2056
|
}
|
|
1692
2057
|
}
|
|
1693
2058
|
|
|
1694
|
-
module.exports = { specSync, listSpecs }
|
|
2059
|
+
module.exports = { specSync, listSpecs, promptHidden }
|