@skitterbyte/skitterspec-linear 10.3.0 → 10.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/core/SETUP.md +13 -1
- package/assets/core/linear.config.md +80 -1
- package/assets/rules/negative-checks.md +69 -0
- package/assets/skills/spec-linear-setup/SKILL.md +35 -4
- package/assets/skills/spec-status/SKILL.md +18 -0
- package/assets/skills/spec-sync/SKILL.md +167 -0
- package/package.json +1 -1
- package/src/init.js +20 -5
- package/src/vendor/linear/api.js +62 -8
- package/src/vendor/linear/cli-sync.js +677 -2
- package/src/vendor/linear/credentials.js +299 -0
- package/src/vendor/linear/doctor.js +179 -0
- package/src/vendor/sync-core/index.js +6 -0
- package/src/vendor/sync-core/src/compare.js +4 -2
- 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,10 +42,24 @@ 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')
|
|
54
|
+
const { runChecks } = require('./doctor.js')
|
|
55
|
+
const {
|
|
56
|
+
storePath,
|
|
57
|
+
storeMode,
|
|
58
|
+
fingerprint,
|
|
59
|
+
writeKey,
|
|
60
|
+
writeKeyCommand,
|
|
61
|
+
removeKey,
|
|
62
|
+
} = require('./credentials.js')
|
|
49
63
|
|
|
50
64
|
// Resolve a spec argument to its snapshot dir. Accepts a spec name/folder found
|
|
51
65
|
// under specs/** (preferred) or a literal path to a snapshot directory.
|
|
@@ -494,6 +508,26 @@ function specSyncVerify(dir, config, specArg, flags, out) {
|
|
|
494
508
|
)
|
|
495
509
|
return 1
|
|
496
510
|
}
|
|
511
|
+
// A `.base.json` is the LAST-PUSHED SNAPSHOT, not a read-back: it stores
|
|
512
|
+
// content HASHES keyed by identifier, never description text. Passed as
|
|
513
|
+
// `--stored` it parses fine and compares a description against a hash, so the
|
|
514
|
+
// report is confidently, entirely wrong — a real field run read "5045
|
|
515
|
+
// character(s) lost" off an intact mirror. Refused here rather than only
|
|
516
|
+
// documented, because a guard is enforceable and prose is not.
|
|
517
|
+
const snapshotRoot = path.resolve(dir, config.sync.baseDir)
|
|
518
|
+
const isSnapshot =
|
|
519
|
+
flags.stored.endsWith('.base.json') || !path.relative(snapshotRoot, flags.stored).startsWith('..')
|
|
520
|
+
if (isSnapshot) {
|
|
521
|
+
out.write(
|
|
522
|
+
`spec-sync verify: ${path.relative(dir, flags.stored)} is a last-pushed snapshot, not a read-back.\n` +
|
|
523
|
+
' It holds content hashes keyed by identifier — comparing one against a\n' +
|
|
524
|
+
' description reports enormous bogus losses on a perfectly intact mirror.\n' +
|
|
525
|
+
' --stored wants what the tracker CURRENTLY holds:\n' +
|
|
526
|
+
' {"issue": "…", "subIssues": {"<ref>": "…"}}\n' +
|
|
527
|
+
' /spec-push reads that back over MCP and writes it for this command.\n',
|
|
528
|
+
)
|
|
529
|
+
return 1
|
|
530
|
+
}
|
|
497
531
|
let stored
|
|
498
532
|
try {
|
|
499
533
|
stored = JSON.parse(fs.readFileSync(flags.stored, 'utf-8'))
|
|
@@ -550,6 +584,373 @@ function verifyLines(snapshotDir, config, stored, identifier) {
|
|
|
550
584
|
return lines
|
|
551
585
|
}
|
|
552
586
|
|
|
587
|
+
/**
|
|
588
|
+
* `spec-sync doctor [--json]` — is this project set up, across every layer?
|
|
589
|
+
*
|
|
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.
|
|
593
|
+
*
|
|
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.
|
|
599
|
+
*
|
|
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.
|
|
604
|
+
*/
|
|
605
|
+
async function specSyncDoctor(dir, flags, out) {
|
|
606
|
+
const state = gatherState(dir, flags)
|
|
607
|
+
if (flags.remoteCheck) state.remote = await checkRemote(state, flags)
|
|
608
|
+
|
|
609
|
+
const report = runChecks(state)
|
|
610
|
+
|
|
611
|
+
if (flags.json) {
|
|
612
|
+
out.write(JSON.stringify(report, null, 2) + '\n')
|
|
613
|
+
return report.ok ? 0 : 1
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const width = Math.max(...report.checks.map((c) => c.label.length))
|
|
617
|
+
const lines = ['skitterspec doctor:']
|
|
618
|
+
for (const c of report.checks) {
|
|
619
|
+
lines.push(` ${c.label.padEnd(width)} ${c.state.padEnd(8)} ${c.detail}`)
|
|
620
|
+
// The fix sits under the row it belongs to, indented past the state column,
|
|
621
|
+
// so a wall of rows still reads as "this one, and here is the command".
|
|
622
|
+
if (c.fix) lines.push(` ${' '.repeat(width)} ${' '.repeat(8)} → ${c.fix}`)
|
|
623
|
+
}
|
|
624
|
+
// Everything not `ok`/`skipped` needs a human's attention — including a
|
|
625
|
+
// `missing` one, which is why the count and the EXIT CODE differ: a declined
|
|
626
|
+
// opt-in is worth reporting but must not fail the run (see `runChecks`).
|
|
627
|
+
const attention = report.checks.filter((c) => c.state === 'broken' || c.state === 'missing').length
|
|
628
|
+
lines.push('')
|
|
629
|
+
lines.push(attention ? ` ${attention} check(s) need attention.` : ' ready.')
|
|
630
|
+
|
|
631
|
+
out.write(lines.join('\n') + '\n')
|
|
632
|
+
return report.ok ? 0 : 1
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* The live half of the report: one `team(id:)` call proving the id resolves and
|
|
637
|
+
* the key is accepted. Well-formed config is not working config.
|
|
638
|
+
*
|
|
639
|
+
* Opt-in because it is the only part that needs the network — the offline checks
|
|
640
|
+
* have to stay usable with no connectivity, which is exactly when a setup
|
|
641
|
+
* problem is most annoying to diagnose.
|
|
642
|
+
*
|
|
643
|
+
* **No API message is ever relayed.** A GraphQL error body can echo the request
|
|
644
|
+
* back, and this is a command a skill prints; so a failure is CLASSIFIED into a
|
|
645
|
+
* short reason of our own words.
|
|
646
|
+
*/
|
|
647
|
+
async function checkRemote(state, flags) {
|
|
648
|
+
if (!state.tracker.parsed || !state.tracker.teamId) {
|
|
649
|
+
return { checked: true, skipped: true, reason: 'no usable tracker config to check against' }
|
|
650
|
+
}
|
|
651
|
+
if (!state.key.ok) {
|
|
652
|
+
return { checked: true, skipped: true, reason: 'no key, so there is nothing to check with' }
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const key = resolveApiKey(state._config, flags.env || process.env)
|
|
656
|
+
const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
|
|
657
|
+
let team
|
|
658
|
+
try {
|
|
659
|
+
team = await adapter.readTeam(state.tracker.teamId)
|
|
660
|
+
} catch (error) {
|
|
661
|
+
const failure = classifyRemoteFailure(error)
|
|
662
|
+
// NEVER ANSWERED is not the same as ANSWERED NO. An unreachable API or a
|
|
663
|
+
// rate-limit means the check did not run — the setup is unexamined, not
|
|
664
|
+
// wrong — so it reports `skipped`, the same state as "you didn't ask for
|
|
665
|
+
// it". Calling it `broken` exited 1 on a healthy project that merely had no
|
|
666
|
+
// network, and every skill branching on that code failed with it.
|
|
667
|
+
if (failure.reached === false) return { checked: true, skipped: true, reason: failure.reason }
|
|
668
|
+
return { checked: true, ok: false, ...failure }
|
|
669
|
+
}
|
|
670
|
+
if (!team || !team.key) {
|
|
671
|
+
return {
|
|
672
|
+
checked: true,
|
|
673
|
+
ok: false,
|
|
674
|
+
reason: 'the key was accepted, but no team has that id',
|
|
675
|
+
fix: '/spec-linear-setup',
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return { checked: true, ok: true, teamKey: team.key, recordedKey: state.tracker.teamKey }
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Map a thrown API error onto our own short reason. Matched on the shapes
|
|
682
|
+
// `api.js` raises; anything unrecognised degrades to a generic line rather than
|
|
683
|
+
// leaking the message.
|
|
684
|
+
//
|
|
685
|
+
// BLIND SPOT: matching on message text, so an api.js rewording lands here as the
|
|
686
|
+
// unrecognised case. That is why the fallback stays `broken` — Linear ANSWERED
|
|
687
|
+
// and refused, which is evidence of a problem even when we cannot name it. Only
|
|
688
|
+
// a request that got no answer at all (`reached: false`) is un-evidence.
|
|
689
|
+
function classifyRemoteFailure(error) {
|
|
690
|
+
const m = String((error && error.message) || '')
|
|
691
|
+
if (/rejected the API key|HTTP 401|HTTP 403/.test(m)) {
|
|
692
|
+
return { reason: 'Linear rejected the key — it may be revoked or for another workspace', fix: 'skitterspec spec-sync credentials set' }
|
|
693
|
+
}
|
|
694
|
+
// `reached: false` — the request never got an answer, so it says nothing about
|
|
695
|
+
// whether this project is set up correctly. See the caller.
|
|
696
|
+
if (/unreachable/.test(m)) {
|
|
697
|
+
return { reason: 'Linear could not be reached — check your connection', fix: null, reached: false }
|
|
698
|
+
}
|
|
699
|
+
if (/rate-limited/.test(m)) {
|
|
700
|
+
return { reason: 'Linear rate-limited the request and did not recover', fix: null, reached: false }
|
|
701
|
+
}
|
|
702
|
+
if (/Entity not found|not found/i.test(m)) {
|
|
703
|
+
return { reason: 'no team with that id in this workspace', fix: '/spec-linear-setup' }
|
|
704
|
+
}
|
|
705
|
+
return { reason: 'Linear did not accept the request', fix: null }
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Read the project's real state for `runChecks`. Never throws: every probe that
|
|
709
|
+
// can fail reports the failure as data.
|
|
710
|
+
function gatherState(dir, flags) {
|
|
711
|
+
const state = { scaffold: {}, isolation: {}, tracker: {}, key: {}, remote: { checked: false } }
|
|
712
|
+
|
|
713
|
+
const specs = path.join(dir, 'specs')
|
|
714
|
+
state.scaffold.specsDir = fs.existsSync(specs)
|
|
715
|
+
if (state.scaffold.specsDir) {
|
|
716
|
+
state.scaffold.core = fs.existsSync(path.join(specs, '.core'))
|
|
717
|
+
// Reported for context only — a missing bucket is normal (see scaffoldCheck).
|
|
718
|
+
state.scaffold.buckets = BUCKETS.filter((b) => fs.existsSync(path.join(specs, b)))
|
|
719
|
+
state.scaffold.skills = countSkills(dir)
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const envFile = path.join(dir, 'specs', '.core', 'env.config.json')
|
|
723
|
+
state.isolation.present = fs.existsSync(envFile)
|
|
724
|
+
if (state.isolation.present) {
|
|
725
|
+
try {
|
|
726
|
+
JSON.parse(fs.readFileSync(envFile, 'utf-8'))
|
|
727
|
+
state.isolation.parsed = true
|
|
728
|
+
} catch (error) {
|
|
729
|
+
state.isolation.parsed = false
|
|
730
|
+
state.isolation.error = error.message
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
state.tracker.present = fs.existsSync(path.join(dir, CONFIG_FILE))
|
|
735
|
+
let config = null
|
|
736
|
+
if (state.tracker.present) {
|
|
737
|
+
try {
|
|
738
|
+
config = loadLinearConfig(dir).config
|
|
739
|
+
state.tracker.parsed = true
|
|
740
|
+
state.tracker.teamId = (config.linear && config.linear.teamId) || ''
|
|
741
|
+
state.tracker.teamKey = (config.linear && config.linear.teamKey) || ''
|
|
742
|
+
} catch (error) {
|
|
743
|
+
state.tracker.parsed = false
|
|
744
|
+
state.tracker.error = error.message
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Without a parsed config there is no `auth.keyEnv` and no team to key on, so
|
|
749
|
+
// there is nothing to look up — the key row reports as skipped instead.
|
|
750
|
+
if (config) {
|
|
751
|
+
const resolved = resolveApiKey(config, flags.env || process.env)
|
|
752
|
+
// A key resolves from the environment, the store, or a `keyCommand` the
|
|
753
|
+
// store runs — `resolveApiKey` covers all three, so the row must not be read
|
|
754
|
+
// as "env var unset".
|
|
755
|
+
//
|
|
756
|
+
// When it does NOT resolve, `resolveApiKey` appends the reason on later
|
|
757
|
+
// lines: a store that is world-readable, or a keyCommand that failed.
|
|
758
|
+
// Dropping it reported a broken command as `no key for SKS` and sent the
|
|
759
|
+
// user to set a key they had already set (`credentials status` keeps it for
|
|
760
|
+
// the same reason).
|
|
761
|
+
const why = resolved.ok ? '' : resolved.error.split('\n').slice(1).map((l) => l.trim()).filter(Boolean).join('; ')
|
|
762
|
+
state.key = resolved.ok
|
|
763
|
+
? { ok: true, source: resolved.source === 'env' ? `the environment (${resolved.envVar})` : resolved.source, fingerprint: fingerprint(resolved.key) }
|
|
764
|
+
: { ok: false, error: `no key for ${state.tracker.teamKey || state.tracker.teamId}${why ? ` — ${why}` : ''}` }
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
state._config = config
|
|
768
|
+
return state
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// How many skills are installed in the target project.
|
|
772
|
+
function countSkills(dir) {
|
|
773
|
+
const skills = path.join(dir, '.claude', 'skills')
|
|
774
|
+
try {
|
|
775
|
+
return fs
|
|
776
|
+
.readdirSync(skills, { withFileTypes: true })
|
|
777
|
+
.filter((e) => (e.isDirectory() || e.isSymbolicLink()) && fs.existsSync(path.join(skills, e.name, 'SKILL.md')))
|
|
778
|
+
.length
|
|
779
|
+
} catch {
|
|
780
|
+
return 0
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* `spec-sync retarget [--yes]` — repoint a mirror after a team-key rename.
|
|
786
|
+
*
|
|
787
|
+
* Renaming a Linear team rewrites the key in every issue identifier, and the
|
|
788
|
+
* repo stamps those in three places. Nothing moved them, so afterwards
|
|
789
|
+
* `/spec-push` fails with `no Linear issue found for SKI-7` — the right failure,
|
|
790
|
+
* with no way out but a hand rewrite (done twice by hand on 2026-09-02).
|
|
791
|
+
*
|
|
792
|
+
* The rewrite itself is provider-neutral and lives in `sync-core/retarget.js`.
|
|
793
|
+
* Only two things here touch Linear:
|
|
794
|
+
*
|
|
795
|
+
* 1. **Detection.** `teamId` survives a rename, so ask Linear for that team's
|
|
796
|
+
* CURRENT key and compare it with the recorded one. A difference IS the
|
|
797
|
+
* rename. It is never taken as an argument: nothing stops a typo rewriting
|
|
798
|
+
* every stamp to a key that does not exist.
|
|
799
|
+
* 2. **One spot-check.** Resolve the first remapped identifier and compare its
|
|
800
|
+
* TITLE to the spec's. That is what makes the number-preservation assumption
|
|
801
|
+
* safe, and it tests identity rather than mere existence — `SKS-7` existing
|
|
802
|
+
* does not make it the issue that was `SKI-7`. One read, which is also what
|
|
803
|
+
* keeps the MCP path viable.
|
|
804
|
+
*/
|
|
805
|
+
async function specSyncRetarget(dir, config, flags, out) {
|
|
806
|
+
const teamId = (config.linear && config.linear.teamId) || ''
|
|
807
|
+
if (!teamId) {
|
|
808
|
+
out.write('spec-sync retarget: no linear.teamId in specs/.core/linear.config.json — nothing to compare against.\n')
|
|
809
|
+
return 1
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const recorded = deriveRecordedKey(dir, config)
|
|
813
|
+
if (!recorded.key) {
|
|
814
|
+
out.write(`spec-sync retarget: cannot tell which key this repo is stamped with.\n ${recorded.reason}\n`)
|
|
815
|
+
return 1
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const key = resolveApiKey(config, flags.env || process.env)
|
|
819
|
+
const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
|
|
820
|
+
|
|
821
|
+
// Linear's MCP `get_team` does not return the team key (observed 2026-09-02),
|
|
822
|
+
// so the one fact detection needs is unavailable there. Report what IS known
|
|
823
|
+
// and let the operator supply it, rather than guessing or silently skipping.
|
|
824
|
+
if (transport === 'mcp') {
|
|
825
|
+
out.write(
|
|
826
|
+
[
|
|
827
|
+
'spec-sync retarget: transport = mcp — cannot read the team key (nothing was changed)',
|
|
828
|
+
` ${key.ok ? '--via mcp was requested' : key.error}`,
|
|
829
|
+
` recorded key: ${recorded.key} (from ${recorded.source})`,
|
|
830
|
+
" Linear's MCP get_team does not return a team key, so a rename cannot be detected here.",
|
|
831
|
+
' Confirm the current key in Linear, then set an API key and re-run for the plan.',
|
|
832
|
+
].join('\n') + '\n',
|
|
833
|
+
)
|
|
834
|
+
return 1
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
|
|
838
|
+
let team
|
|
839
|
+
try {
|
|
840
|
+
team = await adapter.readTeam(teamId)
|
|
841
|
+
} catch (error) {
|
|
842
|
+
out.write(`spec-sync retarget: could not read the team: ${error.message}\n`)
|
|
843
|
+
return 1
|
|
844
|
+
}
|
|
845
|
+
if (!team || !team.key) {
|
|
846
|
+
out.write(`spec-sync retarget: Linear returned no team for ${teamId}\n`)
|
|
847
|
+
return 1
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const header = [
|
|
851
|
+
`spec-sync retarget: team ${teamId} (${team.name || team.key})`,
|
|
852
|
+
` recorded key: ${recorded.key} (from ${recorded.source})`,
|
|
853
|
+
` linear key: ${team.key}${team.key === recorded.key ? '' : ' <- renamed'}`,
|
|
854
|
+
]
|
|
855
|
+
|
|
856
|
+
if (team.key === recorded.key) {
|
|
857
|
+
out.write([...header, '', ' already current — nothing to retarget'].join('\n') + '\n')
|
|
858
|
+
return 0
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const plan = planRetarget({ dir, oldKey: recorded.key, newKey: team.key, config })
|
|
862
|
+
if (isEmptyRetarget(plan)) {
|
|
863
|
+
out.write([...header, '', ' the key moved, but nothing in the repo is stamped with it'].join('\n') + '\n')
|
|
864
|
+
return 0
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Spot-check BEFORE reporting the plan as safe: a wrong mapping is caught
|
|
868
|
+
// here, not after the files have moved.
|
|
869
|
+
const check = await spotCheck({ dir, config, plan, adapter, oldKey: recorded.key, newKey: team.key })
|
|
870
|
+
const lines = [
|
|
871
|
+
...header,
|
|
872
|
+
'',
|
|
873
|
+
' would rewrite:',
|
|
874
|
+
` ${plan.stamps.length} frontmatter stamp(s) (linear_identifier, linear_url, linear_issue_id)`,
|
|
875
|
+
` ${plan.snapshots.length} base snapshot(s) (rename + re-key subIssues)`,
|
|
876
|
+
` ${plan.configKey ? 1 : 0} config key (linear.teamKey)`,
|
|
877
|
+
'',
|
|
878
|
+
` spot-check: ${check.line}`,
|
|
879
|
+
]
|
|
880
|
+
|
|
881
|
+
if (!check.ok) {
|
|
882
|
+
lines.push(' refusing — the mapping is not safe to apply')
|
|
883
|
+
out.write(lines.join('\n') + '\n')
|
|
884
|
+
return 1
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
if (!flags.yes) {
|
|
888
|
+
lines.push(' dry-run — re-run with --yes to apply.')
|
|
889
|
+
out.write(lines.join('\n') + '\n')
|
|
890
|
+
return 0
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const dirty = dirtyPaths(dir)
|
|
894
|
+
if (dirty === null) {
|
|
895
|
+
lines.push(' --yes refused: not a git repository, so the rewrite would not be reviewable')
|
|
896
|
+
out.write(lines.join('\n') + '\n')
|
|
897
|
+
return 1
|
|
898
|
+
}
|
|
899
|
+
if (dirty.length) {
|
|
900
|
+
lines.push(` --yes refused: ${dirty.length} uncommitted change(s) — commit or stash first`)
|
|
901
|
+
for (const d of dirty.slice(0, 10)) lines.push(` ${d}`)
|
|
902
|
+
if (dirty.length > 10) lines.push(` … and ${dirty.length - 10} more`)
|
|
903
|
+
lines.push(' the rewrite must land as one reviewable, revertable change')
|
|
904
|
+
out.write(lines.join('\n') + '\n')
|
|
905
|
+
return 1
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const changed = applyRetarget(plan, { dir, config })
|
|
909
|
+
lines.push(
|
|
910
|
+
' applied:',
|
|
911
|
+
` ${changed.files.length} spec file(s)`,
|
|
912
|
+
` ${changed.snapshots.length} snapshot(s) moved`,
|
|
913
|
+
...(changed.configKey ? [' config linear.teamKey'] : []),
|
|
914
|
+
" nothing was pushed — only the repo's stamps moved; the mirror is untouched.",
|
|
915
|
+
' review the diff, then commit it.',
|
|
916
|
+
)
|
|
917
|
+
out.write(lines.join('\n') + '\n')
|
|
918
|
+
return 0
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Resolve the first remapped SPEC issue and compare its title to the spec's.
|
|
923
|
+
*
|
|
924
|
+
* Deliberately ONE read. Checking every ref instead would cost a request each
|
|
925
|
+
* (~198 on a real repo), force an MCP refusal, and still only prove the issues
|
|
926
|
+
* exist — not that they are the same issues.
|
|
927
|
+
*/
|
|
928
|
+
async function spotCheck({ dir, config, plan, adapter, oldKey, newKey }) {
|
|
929
|
+
const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
|
|
930
|
+
const stamp = plan.stamps.find((s) => path.basename(s.file) === overviewFile)
|
|
931
|
+
if (!stamp) return { ok: true, line: 'skipped — no spec issue in the plan to check' }
|
|
932
|
+
|
|
933
|
+
const before = parseFrontmatter(stamp.from).data.linear_identifier
|
|
934
|
+
if (!before) return { ok: true, line: 'skipped — no linear_identifier in the plan to check' }
|
|
935
|
+
const from = String(before).trim()
|
|
936
|
+
const to = `${newKey}-${from.slice(oldKey.length + 1)}`
|
|
937
|
+
|
|
938
|
+
let issue
|
|
939
|
+
try {
|
|
940
|
+
issue = await adapter.readIssue(to)
|
|
941
|
+
} catch (error) {
|
|
942
|
+
return { ok: false, line: `${to} could not be read — ${error.message}` }
|
|
943
|
+
}
|
|
944
|
+
if (!issue) return { ok: false, line: `${from} → ${to}, but ${to} does not exist in Linear` }
|
|
945
|
+
|
|
946
|
+
const expected = readSnapshot(path.dirname(path.join(dir, stamp.file)), config).title
|
|
947
|
+
const got = issue.title || ''
|
|
948
|
+
if (expected && got && expected.trim() !== got.trim()) {
|
|
949
|
+
return { ok: false, line: `${to} resolves, but its title is "${got}" — the spec's is "${expected}"` }
|
|
950
|
+
}
|
|
951
|
+
return { ok: true, line: `${to} resolves, title matches the spec` }
|
|
952
|
+
}
|
|
953
|
+
|
|
553
954
|
/**
|
|
554
955
|
* `spec-sync states [--json]` — which transport this repo will use, and on the
|
|
555
956
|
* API path the workspace's issue state NAMES.
|
|
@@ -746,13 +1147,22 @@ async function applyOneSpec({ dir, config, snapshotDir, plan, adapter, teamId, p
|
|
|
746
1147
|
}
|
|
747
1148
|
|
|
748
1149
|
// 3. Sub-issue updates — already linked, nothing to stamp.
|
|
1150
|
+
//
|
|
1151
|
+
// Keyed by REF, never by id: step 4 matches the read-back against the
|
|
1152
|
+
// projection, which keys phases by ref. Keying an update by its id made every
|
|
1153
|
+
// updated sub-issue report as a stale ref on every push. A plan written before
|
|
1154
|
+
// updates carried a ref still resolves — by id, off the projection.
|
|
1155
|
+
const refById = new Map()
|
|
1156
|
+
for (const s of projectionOf(snapshotDir, config).subIssues || []) {
|
|
1157
|
+
if (s.id != null) refById.set(String(s.id), s.ref)
|
|
1158
|
+
}
|
|
749
1159
|
for (const sub of (plan.subIssues && plan.subIssues.update) || []) {
|
|
750
1160
|
await adapter.updateIssue(sub.id, withoutNull({
|
|
751
1161
|
title: sub.name,
|
|
752
1162
|
description: sub.goal,
|
|
753
1163
|
stateId: stateId(sub.state),
|
|
754
1164
|
}))
|
|
755
|
-
result.subIssues[sub.ref || sub.id] = sub.id
|
|
1165
|
+
result.subIssues[sub.ref || refById.get(String(sub.id)) || sub.id] = sub.id
|
|
756
1166
|
lines.push(` sub-issue updated: ${sub.id}`)
|
|
757
1167
|
}
|
|
758
1168
|
|
|
@@ -1173,14 +1583,229 @@ function withoutNull(obj) {
|
|
|
1173
1583
|
return out
|
|
1174
1584
|
}
|
|
1175
1585
|
|
|
1586
|
+
// --- credentials -------------------------------------------------------------
|
|
1587
|
+
|
|
1588
|
+
/**
|
|
1589
|
+
* `spec-sync credentials <status|set|unset>` — manage the user-level API key.
|
|
1590
|
+
*
|
|
1591
|
+
* The split here is deliberate and is the whole point of the feature:
|
|
1592
|
+
*
|
|
1593
|
+
* `status` is SAFE FOR A SKILL TO RUN. It reports readiness and never the
|
|
1594
|
+
* value — path, mode, team, and a masked fingerprint.
|
|
1595
|
+
* `set` is for a HUMAN, run outside the model. It reads the key from a TTY
|
|
1596
|
+
* with echo off (or `--stdin`), never from argv.
|
|
1597
|
+
*
|
|
1598
|
+
* A key typed into a chat enters the transcript, is sent to the model and may be
|
|
1599
|
+
* logged; moving where a key is STORED is worthless if it travels through the
|
|
1600
|
+
* conversation to get there. So nothing in this file ever prints a key, and
|
|
1601
|
+
* `--key <value>` is refused rather than supported.
|
|
1602
|
+
*/
|
|
1603
|
+
async function specSyncCredentials(dir, config, action, flags, out) {
|
|
1604
|
+
const env = flags.env || process.env
|
|
1605
|
+
const file = storePath(env)
|
|
1606
|
+
const teamId = (config.linear && config.linear.teamId) || ''
|
|
1607
|
+
const teamKey = (config.linear && config.linear.teamKey) || ''
|
|
1608
|
+
const label = teamKey ? `${teamId} (${teamKey})` : teamId
|
|
1609
|
+
|
|
1610
|
+
if (!teamId) {
|
|
1611
|
+
out.write(
|
|
1612
|
+
'spec-sync credentials: no linear.teamId in specs/.core/linear.config.json.\n' +
|
|
1613
|
+
' The store is keyed by team — run `spec-sync init-config` first.\n',
|
|
1614
|
+
)
|
|
1615
|
+
return 1
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
if (action === 'status' || !action) return credentialsStatus(dir, config, file, label, flags, out)
|
|
1619
|
+
if (action === 'set') return credentialsSet(file, teamId, label, flags, out)
|
|
1620
|
+
if (action === 'unset') return credentialsUnset(file, teamId, label, out)
|
|
1621
|
+
|
|
1622
|
+
out.write('Usage: skitterspec spec-sync credentials <status|set|unset> [--stdin] [--json]\n')
|
|
1623
|
+
return 1
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// Readiness only — the command a skill runs. Never prints the key.
|
|
1627
|
+
function credentialsStatus(dir, config, file, label, flags, out) {
|
|
1628
|
+
const resolved = resolveApiKey(config, flags.env || process.env)
|
|
1629
|
+
const mode = storeMode(file)
|
|
1630
|
+
const present = resolved.ok
|
|
1631
|
+
const payload = {
|
|
1632
|
+
store: file,
|
|
1633
|
+
mode,
|
|
1634
|
+
team: label,
|
|
1635
|
+
key: present ? { present: true, source: resolved.source, fingerprint: fingerprint(resolved.key) } : { present: false },
|
|
1636
|
+
}
|
|
1637
|
+
if (flags.json) {
|
|
1638
|
+
out.write(JSON.stringify(payload, null, 2) + '\n')
|
|
1639
|
+
return present ? 0 : 1
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
const lines = ['spec-sync credentials:']
|
|
1643
|
+
const strayed = repoConfigKeyCommand(dir)
|
|
1644
|
+
if (strayed) {
|
|
1645
|
+
lines.push(
|
|
1646
|
+
' note: a keyCommand in specs/.core/linear.config.json is IGNORED.',
|
|
1647
|
+
' That file is committed, so a command there would run on the',
|
|
1648
|
+
' machine of anyone who cloned the repo. Record it here instead:',
|
|
1649
|
+
' skitterspec spec-sync credentials set --command <cmd>',
|
|
1650
|
+
)
|
|
1651
|
+
}
|
|
1652
|
+
lines.push(` store: ${file}${mode ? ` (${mode})` : ' (not created yet)'}`)
|
|
1653
|
+
lines.push(` team: ${label}`)
|
|
1654
|
+
if (present) {
|
|
1655
|
+
const where =
|
|
1656
|
+
resolved.source === 'env'
|
|
1657
|
+
? `environment (${resolved.envVar})`
|
|
1658
|
+
: resolved.source === 'command'
|
|
1659
|
+
? 'keyCommand'
|
|
1660
|
+
: 'store'
|
|
1661
|
+
lines.push(` key: set — ${fingerprint(resolved.key)} from the ${where}`)
|
|
1662
|
+
if (resolved.command) lines.push(` runs: ${resolved.command}`)
|
|
1663
|
+
} else {
|
|
1664
|
+
lines.push(' key: not set')
|
|
1665
|
+
// `resolveApiKey` appends a reason when the store or its keyCommand is
|
|
1666
|
+
// broken rather than merely absent. Dropping it here would report a failing
|
|
1667
|
+
// command as "you never set a key" and send the user to set it again.
|
|
1668
|
+
for (const detail of resolved.error.split('\n').slice(1)) {
|
|
1669
|
+
if (detail.trim()) lines.push(` problem:${detail.replace(/^ +/, ' ')}`)
|
|
1670
|
+
}
|
|
1671
|
+
lines.push('')
|
|
1672
|
+
lines.push(' Run this yourself, in your own terminal — not through an assistant:')
|
|
1673
|
+
lines.push(' skitterspec spec-sync credentials set')
|
|
1674
|
+
}
|
|
1675
|
+
out.write(lines.join('\n') + '\n')
|
|
1676
|
+
return present ? 0 : 1
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
// The human-facing setter. TTY prompt with echo off, or `--stdin` for a pipe.
|
|
1680
|
+
async function credentialsSet(file, teamId, label, flags, out) {
|
|
1681
|
+
if (flags.keyArgGiven) {
|
|
1682
|
+
out.write(
|
|
1683
|
+
'spec-sync credentials: --key is not supported, on purpose.\n' +
|
|
1684
|
+
' A secret in the command line is visible in shell history and to `ps`.\n' +
|
|
1685
|
+
' Run `credentials set` with no arguments and paste at the prompt (input\n' +
|
|
1686
|
+
' is hidden), or pipe it: `… | credentials set --stdin`.\n',
|
|
1687
|
+
)
|
|
1688
|
+
return 1
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// A command is not a secret — it names WHERE the key lives, so unlike --key it
|
|
1692
|
+
// is safe on the command line and nothing is prompted for.
|
|
1693
|
+
if (flags.command) {
|
|
1694
|
+
const r = writeKeyCommand(file, teamId, flags.command)
|
|
1695
|
+
if (!r.ok) {
|
|
1696
|
+
out.write(`spec-sync credentials: ${r.reason}\n`)
|
|
1697
|
+
return 1
|
|
1698
|
+
}
|
|
1699
|
+
out.write(
|
|
1700
|
+
`spec-sync credentials: ${label} will resolve its key by running:\n` +
|
|
1701
|
+
` ${flags.command}\n` +
|
|
1702
|
+
` recorded in ${r.path} (600)\n`,
|
|
1703
|
+
)
|
|
1704
|
+
return 0
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
let key
|
|
1708
|
+
if (flags.stdin) {
|
|
1709
|
+
key = (await readAllStdin(flags.input || process.stdin)).trim()
|
|
1710
|
+
if (!key) {
|
|
1711
|
+
out.write('spec-sync credentials: nothing on stdin — no key stored.\n')
|
|
1712
|
+
return 1
|
|
1713
|
+
}
|
|
1714
|
+
} else {
|
|
1715
|
+
const input = flags.input || process.stdin
|
|
1716
|
+
if (!input.isTTY) {
|
|
1717
|
+
out.write(
|
|
1718
|
+
'spec-sync credentials: not a terminal — cannot prompt for a key.\n' +
|
|
1719
|
+
' Run it in your own terminal, or pipe the key with --stdin.\n',
|
|
1720
|
+
)
|
|
1721
|
+
return 1
|
|
1722
|
+
}
|
|
1723
|
+
key = (await promptHidden(`Linear personal API key for ${label} (hidden): `, input, out)).trim()
|
|
1724
|
+
if (!key) {
|
|
1725
|
+
out.write('spec-sync credentials: empty input — no key stored.\n')
|
|
1726
|
+
return 1
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
const r = writeKey(file, teamId, key)
|
|
1731
|
+
if (!r.ok) {
|
|
1732
|
+
out.write(`spec-sync credentials: ${r.reason}\n`)
|
|
1733
|
+
return 1
|
|
1734
|
+
}
|
|
1735
|
+
out.write(`spec-sync credentials: key stored for ${label} in ${r.path} (600)\n`)
|
|
1736
|
+
return 0
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
function credentialsUnset(file, teamId, label, out) {
|
|
1740
|
+
const r = removeKey(file, teamId)
|
|
1741
|
+
if (!r.ok) {
|
|
1742
|
+
out.write(`spec-sync credentials: ${r.reason}\n`)
|
|
1743
|
+
return 1
|
|
1744
|
+
}
|
|
1745
|
+
out.write(
|
|
1746
|
+
r.removed
|
|
1747
|
+
? `spec-sync credentials: removed the key for ${label} from ${r.path}\n`
|
|
1748
|
+
: `spec-sync credentials: no key stored for ${label} — nothing to remove\n`,
|
|
1749
|
+
)
|
|
1750
|
+
return 0
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// Is a keyCommand set in the REPO's committed config? It is never honoured — the
|
|
1754
|
+
// loader drops unknown keys — but silently ignoring it would leave someone
|
|
1755
|
+
// wondering why their command never runs, so `status` calls it out.
|
|
1756
|
+
function repoConfigKeyCommand(dir) {
|
|
1757
|
+
try {
|
|
1758
|
+
const raw = fs.readFileSync(path.join(dir, CONFIG_FILE), 'utf-8')
|
|
1759
|
+
const parsed = JSON.parse(raw)
|
|
1760
|
+
const auth = parsed && parsed.auth
|
|
1761
|
+
return auth && typeof auth.keyCommand === 'string' && auth.keyCommand.trim()
|
|
1762
|
+
? auth.keyCommand.trim()
|
|
1763
|
+
: null
|
|
1764
|
+
} catch {
|
|
1765
|
+
return null
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// Read stdin to completion (for `--stdin`).
|
|
1770
|
+
function readAllStdin(input) {
|
|
1771
|
+
return new Promise((resolve, reject) => {
|
|
1772
|
+
let data = ''
|
|
1773
|
+
input.setEncoding('utf-8')
|
|
1774
|
+
input.on('data', (chunk) => (data += chunk))
|
|
1775
|
+
input.on('end', () => resolve(data))
|
|
1776
|
+
input.on('error', reject)
|
|
1777
|
+
})
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// Prompt on a TTY with the input hidden. `_writeToOutput` is readline's own echo
|
|
1781
|
+
// hook — silencing it is what keeps the key off the screen (and out of a
|
|
1782
|
+
// screen-shared terminal or a recorded session).
|
|
1783
|
+
function promptHidden(question, input, out) {
|
|
1784
|
+
const readline = require('node:readline')
|
|
1785
|
+
return new Promise((resolve) => {
|
|
1786
|
+
const rl = readline.createInterface({ input, output: process.stdout, terminal: true })
|
|
1787
|
+
out.write(question)
|
|
1788
|
+
rl.question('', (answer) => {
|
|
1789
|
+
out.write('\n')
|
|
1790
|
+
rl.close()
|
|
1791
|
+
resolve(answer)
|
|
1792
|
+
})
|
|
1793
|
+
rl._writeToOutput = () => {}
|
|
1794
|
+
})
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1176
1797
|
async function specSync(rest, io = {}) {
|
|
1177
1798
|
const out = io.out || process.stdout
|
|
1178
1799
|
const err = io.err || process.stderr
|
|
1179
1800
|
const [sub, ...args] = rest
|
|
1180
1801
|
let dir = io.cwd || process.cwd()
|
|
1181
1802
|
const positional = []
|
|
1803
|
+
// Anything `--`-prefixed that no branch below consumed. Collected rather than
|
|
1804
|
+
// pushed onto `positional`, where it was silently discarded — see the refusal
|
|
1805
|
+
// after the loop.
|
|
1806
|
+
const unknownFlags = []
|
|
1182
1807
|
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,
|
|
1183
|
-
force: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null }
|
|
1808
|
+
force: false, yes: false, remoteCheck: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null }
|
|
1184
1809
|
for (let i = 0; i < args.length; i++) {
|
|
1185
1810
|
if (args[i] === '--dir') dir = path.resolve(args[++i])
|
|
1186
1811
|
else if (args[i] === '--json') flags.json = true
|
|
@@ -1196,6 +1821,18 @@ async function specSync(rest, io = {}) {
|
|
|
1196
1821
|
else if (args[i] === '--url') flags.url = args[++i]
|
|
1197
1822
|
else if (args[i] === '--sub') flags.subs.push(args[++i])
|
|
1198
1823
|
else if (args[i] === '--force') flags.force = true
|
|
1824
|
+
else if (args[i] === '--yes') flags.yes = true
|
|
1825
|
+
else if (args[i] === '--check-remote') flags.remoteCheck = true
|
|
1826
|
+
else if (args[i] === '--stdin') flags.stdin = true
|
|
1827
|
+
else if (args[i] === '--command') flags.command = String(args[++i] || '').trim()
|
|
1828
|
+
else if (args[i] === '--key') {
|
|
1829
|
+
// Consumed and DELIBERATELY DISCARDED. A secret in argv is visible in
|
|
1830
|
+
// shell history and to `ps`, so this is refused rather than supported —
|
|
1831
|
+
// but it must still be swallowed here, or the value would fall through to
|
|
1832
|
+
// `positional` and end up printed back in a usage message.
|
|
1833
|
+
i++
|
|
1834
|
+
flags.keyArgGiven = true
|
|
1835
|
+
}
|
|
1199
1836
|
else if (args[i] === '--team-id') flags.teamId = String(args[++i] || '').trim()
|
|
1200
1837
|
else if (args[i] === '--team-key') flags.teamKey = String(args[++i] || '').trim()
|
|
1201
1838
|
else if (args[i] === '--project-id') flags.projectId = String(args[++i] || '').trim()
|
|
@@ -1208,8 +1845,34 @@ async function specSync(rest, io = {}) {
|
|
|
1208
1845
|
const [bucket, ...rest] = String(args[++i] || '').split('=')
|
|
1209
1846
|
flags.stateNames[String(bucket).trim()] = rest.join('=').trim()
|
|
1210
1847
|
} else if (args[i] === '--states') flags.statesFile = path.resolve(args[++i])
|
|
1848
|
+
else if (args[i].startsWith('--')) unknownFlags.push(args[i])
|
|
1211
1849
|
else positional.push(args[i])
|
|
1212
1850
|
}
|
|
1851
|
+
// REFUSE AN UNKNOWN FLAG, before anything runs.
|
|
1852
|
+
//
|
|
1853
|
+
// These used to land in `positional` and vanish. That is merely untidy for a
|
|
1854
|
+
// typo, but it turned a RENAMED flag into a silent no-op: `--write` moved to
|
|
1855
|
+
// `--yes` when `doctor` became `retarget`, so `spec-sync doctor --write` — the
|
|
1856
|
+
// exact 10.4.0 invocation for repairing a renamed team — parsed, ran the
|
|
1857
|
+
// readiness report instead, ignored the flag and exited 0. A script would read
|
|
1858
|
+
// that as "repaired".
|
|
1859
|
+
//
|
|
1860
|
+
// A spec name never starts with `--`, so this cannot swallow a real argument.
|
|
1861
|
+
if (unknownFlags.length) {
|
|
1862
|
+
const lines = [`spec-sync: unknown flag ${unknownFlags.join(', ')}`]
|
|
1863
|
+
// Renamed flags get a specific hand-off; a bare "unknown flag" would leave
|
|
1864
|
+
// the caller to guess what replaced it.
|
|
1865
|
+
if (unknownFlags.includes('--write')) {
|
|
1866
|
+
lines.push(
|
|
1867
|
+
' --write was replaced by --yes, and the command that repairs a renamed',
|
|
1868
|
+
' team is now `spec-sync retarget --yes` (it was `doctor --write`).',
|
|
1869
|
+
)
|
|
1870
|
+
}
|
|
1871
|
+
lines.push(' run `skitterspec spec-sync` for the full usage.')
|
|
1872
|
+
out.write(lines.join('\n') + '\n')
|
|
1873
|
+
return 1
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1213
1876
|
dir = path.resolve(dir)
|
|
1214
1877
|
// Injection seam, alongside cwd/out/err: `env` supplies the key lookup and
|
|
1215
1878
|
// `adapter`/`fetch` stand in for the network, so `apply` is exercised end to
|
|
@@ -1223,6 +1886,11 @@ async function specSync(rest, io = {}) {
|
|
|
1223
1886
|
// malformed enough for the loader to throw on.
|
|
1224
1887
|
if (sub === 'init-config') return specSyncInitConfig(dir, flags, out)
|
|
1225
1888
|
|
|
1889
|
+
// Same reason as init-config: this is the command you run when the config is
|
|
1890
|
+
// missing or malformed, so it must not sit behind a loader that throws on the
|
|
1891
|
+
// one and short-circuits on the other.
|
|
1892
|
+
if (sub === 'doctor') return await specSyncDoctor(dir, flags, out)
|
|
1893
|
+
|
|
1226
1894
|
const { config, present } = loadLinearConfig(dir)
|
|
1227
1895
|
if (!present) {
|
|
1228
1896
|
out.write(
|
|
@@ -1247,6 +1915,8 @@ async function specSync(rest, io = {}) {
|
|
|
1247
1915
|
return (await specSyncProjects(dir, config, flags, out)) || 0
|
|
1248
1916
|
case 'states':
|
|
1249
1917
|
return (await specSyncStates(dir, config, flags, out)) || 0
|
|
1918
|
+
case 'retarget':
|
|
1919
|
+
return (await specSyncRetarget(dir, config, flags, out)) || 0
|
|
1250
1920
|
case 'apply':
|
|
1251
1921
|
return (await specSyncApply(dir, config, positional[0], flags, out)) || 0
|
|
1252
1922
|
case 'verify':
|
|
@@ -1254,8 +1924,11 @@ async function specSync(rest, io = {}) {
|
|
|
1254
1924
|
case 'linked':
|
|
1255
1925
|
specSyncLinked(dir, config, flags, out)
|
|
1256
1926
|
return 0
|
|
1927
|
+
case 'credentials':
|
|
1928
|
+
return await specSyncCredentials(dir, config, positional[0], flags, out)
|
|
1257
1929
|
default:
|
|
1258
1930
|
out.write('Usage: skitterspec spec-sync <normalize|record|status> <spec> [--json] [--remote file] [--workspace-states file]\n' +
|
|
1931
|
+
' skitterspec spec-sync credentials <status|set|unset> [--stdin] [--json]\n' +
|
|
1259
1932
|
' skitterspec spec-sync push <spec> --workspace-states <file> [--json] [--skip-state-check]\n' +
|
|
1260
1933
|
' skitterspec spec-sync stamp <spec> --issue KEY-1 [--url URL] [--sub <ref>=KEY-2 …]\n' +
|
|
1261
1934
|
' skitterspec spec-sync states [--via api|mcp] [--json]\n' +
|
|
@@ -1264,6 +1937,8 @@ async function specSync(rest, io = {}) {
|
|
|
1264
1937
|
' skitterspec spec-sync apply --all <bucket> [--via api|mcp] [--json]\n' +
|
|
1265
1938
|
' skitterspec spec-sync verify <spec> --stored <file>\n' +
|
|
1266
1939
|
' skitterspec spec-sync linked [--json]\n' +
|
|
1940
|
+
' skitterspec spec-sync retarget [--yes]\n' +
|
|
1941
|
+
' skitterspec spec-sync doctor [--check-remote] [--json]\n' +
|
|
1267
1942
|
' skitterspec spec-sync init-config --team-id <id> [--team-key K] [--project-id id]\n' +
|
|
1268
1943
|
' [--intake-label L] [--bug-labels a,b] [--hotfix-labels a,b]\n' +
|
|
1269
1944
|
' [--state <bucket>=<name> …] [--states <file>] [--force] [--json]\n')
|