@skitterbyte/skitterspec-linear 10.2.0 → 10.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/core/linear.config.md +53 -0
- package/assets/skills/spec-linear-setup/SKILL.md +23 -3
- package/assets/skills/spec-sync/SKILL.md +177 -0
- package/bin/skitterspec-linear.js +27 -13
- package/package.json +1 -1
- package/src/cli.js +25 -2
- package/src/init.js +15 -5
- package/src/vendor/linear/api.js +62 -8
- package/src/vendor/linear/cli-sync.js +435 -10
- package/src/vendor/linear/commands.js +54 -0
- package/src/vendor/linear/credentials.js +299 -0
- package/src/vendor/linear/doctor.js +341 -0
- package/src/vendor/sync-core/src/compare.js +4 -2
|
@@ -46,6 +46,15 @@ const {
|
|
|
46
46
|
|
|
47
47
|
const { loadLinearConfig, mergeConfig, defaults: configDefaults, CONFIG_FILE, LIFECYCLE_BUCKETS } = require('./config.js')
|
|
48
48
|
const { resolveApiKey, makeApiAdapter, stateIdFor, fetchWorkspaceStates } = require('./api.js')
|
|
49
|
+
const { scanDrift, isClean, fileCount, dirtyPaths, repairDrift } = require('./doctor.js')
|
|
50
|
+
const {
|
|
51
|
+
storePath,
|
|
52
|
+
storeMode,
|
|
53
|
+
fingerprint,
|
|
54
|
+
writeKey,
|
|
55
|
+
writeKeyCommand,
|
|
56
|
+
removeKey,
|
|
57
|
+
} = require('./credentials.js')
|
|
49
58
|
|
|
50
59
|
// Resolve a spec argument to its snapshot dir. Accepts a spec name/folder found
|
|
51
60
|
// under specs/** (preferred) or a literal path to a snapshot directory.
|
|
@@ -158,8 +167,15 @@ function warnToErr(snapshotDir, config, err) {
|
|
|
158
167
|
if (lines.length) err.write(lines.join('\n') + '\n')
|
|
159
168
|
}
|
|
160
169
|
|
|
170
|
+
// Resolve a spec argument to its folder, or null with a message on stdout.
|
|
171
|
+
// EVERY failure path here must print and EVERY caller must return a non-zero
|
|
172
|
+
// code: /spec-push checks $? before it applies a plan, so a resolve failure that
|
|
173
|
+
// exits 0 reads as "nothing to do" rather than "I could not find the spec".
|
|
161
174
|
function resolveOrExit(specArg, dir, out) {
|
|
162
|
-
if (!specArg)
|
|
175
|
+
if (!specArg) {
|
|
176
|
+
out.write('spec-sync: no spec given\n')
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
163
179
|
const snapshotDir = resolveSnapshotDir(specArg, dir)
|
|
164
180
|
if (!snapshotDir) {
|
|
165
181
|
out.write(`spec-sync: spec not found: ${specArg}\n`)
|
|
@@ -171,7 +187,7 @@ function resolveOrExit(specArg, dir, out) {
|
|
|
171
187
|
// `spec-sync normalize <spec>` — print the local projection as JSON.
|
|
172
188
|
function specSyncNormalize(dir, config, specArg, out, err) {
|
|
173
189
|
const snapshotDir = resolveOrExit(specArg, dir, out)
|
|
174
|
-
if (!snapshotDir) return
|
|
190
|
+
if (!snapshotDir) return 1
|
|
175
191
|
// stdout is the projection and nothing else — callers pipe it into jq.
|
|
176
192
|
warnToErr(snapshotDir, config, err)
|
|
177
193
|
out.write(JSON.stringify(projectionOf(snapshotDir, config), null, 2) + '\n')
|
|
@@ -404,7 +420,7 @@ function specSyncStamp(dir, config, specArg, flags, out) {
|
|
|
404
420
|
// files. The skill calls this AFTER applying the plan and stamping new ids.
|
|
405
421
|
function specSyncRecord(dir, config, specArg, out) {
|
|
406
422
|
const snapshotDir = resolveOrExit(specArg, dir, out)
|
|
407
|
-
if (!snapshotDir) return
|
|
423
|
+
if (!snapshotDir) return 1
|
|
408
424
|
const identifier = specIdentifier(snapshotDir, config)
|
|
409
425
|
const file = recordPush({ dir, snapshotDir, identifier, config })
|
|
410
426
|
out.write(`spec-sync record: snapshot written → ${path.relative(dir, file)}\n`)
|
|
@@ -417,7 +433,7 @@ function specSyncRecord(dir, config, specArg, out) {
|
|
|
417
433
|
// configured state names and fails loudly on a typo Linear would silently no-op.
|
|
418
434
|
function specSyncStatus(dir, config, specArg, flags, out) {
|
|
419
435
|
const snapshotDir = resolveOrExit(specArg, dir, out)
|
|
420
|
-
if (!snapshotDir) return
|
|
436
|
+
if (!snapshotDir) return 1
|
|
421
437
|
const identifier = specIdentifier(snapshotDir, config)
|
|
422
438
|
const lines = [`spec-sync status: ${identifier}`, ...warningLines(snapshotDir, config)]
|
|
423
439
|
|
|
@@ -487,6 +503,26 @@ function specSyncVerify(dir, config, specArg, flags, out) {
|
|
|
487
503
|
)
|
|
488
504
|
return 1
|
|
489
505
|
}
|
|
506
|
+
// A `.base.json` is the LAST-PUSHED SNAPSHOT, not a read-back: it stores
|
|
507
|
+
// content HASHES keyed by identifier, never description text. Passed as
|
|
508
|
+
// `--stored` it parses fine and compares a description against a hash, so the
|
|
509
|
+
// report is confidently, entirely wrong — a real field run read "5045
|
|
510
|
+
// character(s) lost" off an intact mirror. Refused here rather than only
|
|
511
|
+
// documented, because a guard is enforceable and prose is not.
|
|
512
|
+
const snapshotRoot = path.resolve(dir, config.sync.baseDir)
|
|
513
|
+
const isSnapshot =
|
|
514
|
+
flags.stored.endsWith('.base.json') || !path.relative(snapshotRoot, flags.stored).startsWith('..')
|
|
515
|
+
if (isSnapshot) {
|
|
516
|
+
out.write(
|
|
517
|
+
`spec-sync verify: ${path.relative(dir, flags.stored)} is a last-pushed snapshot, not a read-back.\n` +
|
|
518
|
+
' It holds content hashes keyed by identifier — comparing one against a\n' +
|
|
519
|
+
' description reports enormous bogus losses on a perfectly intact mirror.\n' +
|
|
520
|
+
' --stored wants what the tracker CURRENTLY holds:\n' +
|
|
521
|
+
' {"issue": "…", "subIssues": {"<ref>": "…"}}\n' +
|
|
522
|
+
' /spec-push reads that back over MCP and writes it for this command.\n',
|
|
523
|
+
)
|
|
524
|
+
return 1
|
|
525
|
+
}
|
|
490
526
|
let stored
|
|
491
527
|
try {
|
|
492
528
|
stored = JSON.parse(fs.readFileSync(flags.stored, 'utf-8'))
|
|
@@ -543,6 +579,160 @@ function verifyLines(snapshotDir, config, stored, identifier) {
|
|
|
543
579
|
return lines
|
|
544
580
|
}
|
|
545
581
|
|
|
582
|
+
/**
|
|
583
|
+
* `spec-sync doctor [--json]` — identifier drift against the team's CURRENT key.
|
|
584
|
+
*
|
|
585
|
+
* A Linear team rename leaves every stamped identifier, the config `teamKey` and
|
|
586
|
+
* every snapshot filename on the old prefix, and nothing noticed. This reports
|
|
587
|
+
* it. Read-only: exit 0 whether or not it finds drift; non-zero only when it
|
|
588
|
+
* could not look (no key, MCP, unreadable team).
|
|
589
|
+
*
|
|
590
|
+
* Two things it deliberately does NOT do:
|
|
591
|
+
*
|
|
592
|
+
* 1. It does not trust `config.linear.teamKey` for the current key — that value
|
|
593
|
+
* is itself one of the things that goes stale, so trusting it would make the
|
|
594
|
+
* drift invisible. `teamId` survives a rename; the key does not.
|
|
595
|
+
* 2. It does not resolve refs with a bulk `team.issues` query. That connection
|
|
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).
|
|
601
|
+
*/
|
|
602
|
+
async function specSyncDoctor(dir, config, flags, out) {
|
|
603
|
+
const key = resolveApiKey(config, flags.env || process.env)
|
|
604
|
+
const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
|
|
605
|
+
|
|
606
|
+
// One read PER DRIFTED REF is the whole design; over MCP that is a model
|
|
607
|
+
// round-trip each, which is impractical at the scale this exists for (198 refs
|
|
608
|
+
// in the run that motivated it). Refused rather than quietly slow — the same
|
|
609
|
+
// call `apply --all` makes.
|
|
610
|
+
if (transport === 'mcp') {
|
|
611
|
+
out.write(
|
|
612
|
+
[
|
|
613
|
+
'spec-sync doctor: needs the api transport (nothing was read)',
|
|
614
|
+
` ${key.ok ? '--via mcp was requested' : key.error}`,
|
|
615
|
+
' it reads one issue per drifted ref, which MCP would route through the model.',
|
|
616
|
+
].join('\n') + '\n',
|
|
617
|
+
)
|
|
618
|
+
return 1
|
|
619
|
+
}
|
|
620
|
+
|
|
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
|
+
const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
|
|
628
|
+
let team
|
|
629
|
+
try {
|
|
630
|
+
team = await adapter.readTeam(teamId)
|
|
631
|
+
} catch (error) {
|
|
632
|
+
out.write(`spec-sync doctor: could not read the team: ${error.message}\n`)
|
|
633
|
+
return 1
|
|
634
|
+
}
|
|
635
|
+
if (!team || !team.key) {
|
|
636
|
+
out.write(`spec-sync doctor: Linear returned no team for ${teamId}\n`)
|
|
637
|
+
return 1
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const drift = scanDrift(dir, config, team.key)
|
|
641
|
+
|
|
642
|
+
// Only the DISTINCT drifted identifiers are checked, so 221 stamps of 198
|
|
643
|
+
// identifiers cost 198 reads.
|
|
644
|
+
const missing = []
|
|
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
|
+
}
|
|
655
|
+
|
|
656
|
+
if (flags.json) {
|
|
657
|
+
out.write(JSON.stringify({ team: { id: team.id, key: team.key, name: team.name }, ...drift, missing }, null, 2) + '\n')
|
|
658
|
+
return 0
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const was = [...new Set(drift.refs.map((r) => r.from.split('-')[0]))]
|
|
662
|
+
const lines = [`spec-sync doctor: team ${team.key}${was.length ? ` (stamps still on ${was.join(', ')})` : ''}`]
|
|
663
|
+
if (isClean(drift)) {
|
|
664
|
+
lines.push(' drift: none — every stamped identifier is on the current team key')
|
|
665
|
+
out.write(lines.join('\n') + '\n')
|
|
666
|
+
return 0
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const driftLines = []
|
|
670
|
+
const stamped = drift.stamps.length + drift.urls.length
|
|
671
|
+
if (stamped) driftLines.push(`${stamped} stamp(s) across ${fileCount(drift)} file(s), ${drift.refs.length} distinct ref(s)`)
|
|
672
|
+
if (drift.snapshots.length || drift.snapshotKeys.length) {
|
|
673
|
+
const parts = []
|
|
674
|
+
if (drift.snapshots.length) parts.push(`${drift.snapshots.length} filename(s)`)
|
|
675
|
+
if (drift.snapshotKeys.length) parts.push(`${drift.snapshotKeys.length} sub-issue key(s)`)
|
|
676
|
+
driftLines.push(`snapshots: ${parts.join(' + ')} under ${config.sync.baseDir}`)
|
|
677
|
+
}
|
|
678
|
+
if (drift.config) driftLines.push(`config linear.teamKey = "${drift.config.from}"`)
|
|
679
|
+
lines.push(` drift: ${driftLines[0]}`)
|
|
680
|
+
for (const l of driftLines.slice(1)) lines.push(` ${l}`)
|
|
681
|
+
|
|
682
|
+
// Reported SEPARATELY from drift: a ref that resolves is repairable, one that
|
|
683
|
+
// does not is a different problem and must not be silently rewritten.
|
|
684
|
+
// Prose mentions are NOT repaired, so they are reported outside the drift
|
|
685
|
+
// block — a report that folded them in would imply --write fixes them.
|
|
686
|
+
if (drift.mentions.length) {
|
|
687
|
+
lines.push(
|
|
688
|
+
` mentions: ${drift.mentions.length} stale ref(s) in spec prose — reported, NOT repaired by --write`,
|
|
689
|
+
)
|
|
690
|
+
}
|
|
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
|
+
|
|
695
|
+
if (!flags.write) {
|
|
696
|
+
lines.push(' run with --write to repair (requires a clean git tree)')
|
|
697
|
+
out.write(lines.join('\n') + '\n')
|
|
698
|
+
return 0
|
|
699
|
+
}
|
|
700
|
+
|
|
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
|
+
const dirty = dirtyPaths(dir)
|
|
706
|
+
if (dirty === null) {
|
|
707
|
+
lines.push(' --write refused: not a git repository, so the rewrite would not be reviewable')
|
|
708
|
+
out.write(lines.join('\n') + '\n')
|
|
709
|
+
return 1
|
|
710
|
+
}
|
|
711
|
+
if (dirty.length) {
|
|
712
|
+
lines.push(` --write refused: ${dirty.length} uncommitted change(s) — commit or stash first`)
|
|
713
|
+
for (const d of dirty.slice(0, 10)) lines.push(` ${d}`)
|
|
714
|
+
if (dirty.length > 10) lines.push(` … and ${dirty.length - 10} more`)
|
|
715
|
+
lines.push(' the repair is one large diff; it must be reviewable on its own')
|
|
716
|
+
out.write(lines.join('\n') + '\n')
|
|
717
|
+
return 1
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const skip = new Set(missing.map((m) => m.from))
|
|
721
|
+
const changed = repairDrift(dir, config, drift, { skip })
|
|
722
|
+
lines.push(' repaired:')
|
|
723
|
+
lines.push(` ${changed.files.length} spec file(s)`)
|
|
724
|
+
lines.push(` ${changed.snapshots.length} snapshot file(s) moved`)
|
|
725
|
+
if (changed.config) lines.push(' config linear.teamKey')
|
|
726
|
+
if (changed.skipped) {
|
|
727
|
+
lines.push(` ${changed.skipped} ref(s) LEFT ALONE — they resolve to no issue under ${team.key}`)
|
|
728
|
+
}
|
|
729
|
+
lines.push(' review the diff, then commit it')
|
|
730
|
+
out.write(lines.join('\n') + '\n')
|
|
731
|
+
// Non-zero when anything was left behind, so a caller cannot read a partial
|
|
732
|
+
// repair as a complete one.
|
|
733
|
+
return changed.skipped ? 1 : 0
|
|
734
|
+
}
|
|
735
|
+
|
|
546
736
|
/**
|
|
547
737
|
* `spec-sync states [--json]` — which transport this repo will use, and on the
|
|
548
738
|
* API path the workspace's issue state NAMES.
|
|
@@ -739,13 +929,22 @@ async function applyOneSpec({ dir, config, snapshotDir, plan, adapter, teamId, p
|
|
|
739
929
|
}
|
|
740
930
|
|
|
741
931
|
// 3. Sub-issue updates — already linked, nothing to stamp.
|
|
932
|
+
//
|
|
933
|
+
// Keyed by REF, never by id: step 4 matches the read-back against the
|
|
934
|
+
// projection, which keys phases by ref. Keying an update by its id made every
|
|
935
|
+
// updated sub-issue report as a stale ref on every push. A plan written before
|
|
936
|
+
// updates carried a ref still resolves — by id, off the projection.
|
|
937
|
+
const refById = new Map()
|
|
938
|
+
for (const s of projectionOf(snapshotDir, config).subIssues || []) {
|
|
939
|
+
if (s.id != null) refById.set(String(s.id), s.ref)
|
|
940
|
+
}
|
|
742
941
|
for (const sub of (plan.subIssues && plan.subIssues.update) || []) {
|
|
743
942
|
await adapter.updateIssue(sub.id, withoutNull({
|
|
744
943
|
title: sub.name,
|
|
745
944
|
description: sub.goal,
|
|
746
945
|
stateId: stateId(sub.state),
|
|
747
946
|
}))
|
|
748
|
-
result.subIssues[sub.ref || sub.id] = sub.id
|
|
947
|
+
result.subIssues[sub.ref || refById.get(String(sub.id)) || sub.id] = sub.id
|
|
749
948
|
lines.push(` sub-issue updated: ${sub.id}`)
|
|
750
949
|
}
|
|
751
950
|
|
|
@@ -1166,6 +1365,217 @@ function withoutNull(obj) {
|
|
|
1166
1365
|
return out
|
|
1167
1366
|
}
|
|
1168
1367
|
|
|
1368
|
+
// --- credentials -------------------------------------------------------------
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* `spec-sync credentials <status|set|unset>` — manage the user-level API key.
|
|
1372
|
+
*
|
|
1373
|
+
* The split here is deliberate and is the whole point of the feature:
|
|
1374
|
+
*
|
|
1375
|
+
* `status` is SAFE FOR A SKILL TO RUN. It reports readiness and never the
|
|
1376
|
+
* value — path, mode, team, and a masked fingerprint.
|
|
1377
|
+
* `set` is for a HUMAN, run outside the model. It reads the key from a TTY
|
|
1378
|
+
* with echo off (or `--stdin`), never from argv.
|
|
1379
|
+
*
|
|
1380
|
+
* A key typed into a chat enters the transcript, is sent to the model and may be
|
|
1381
|
+
* logged; moving where a key is STORED is worthless if it travels through the
|
|
1382
|
+
* conversation to get there. So nothing in this file ever prints a key, and
|
|
1383
|
+
* `--key <value>` is refused rather than supported.
|
|
1384
|
+
*/
|
|
1385
|
+
async function specSyncCredentials(dir, config, action, flags, out) {
|
|
1386
|
+
const env = flags.env || process.env
|
|
1387
|
+
const file = storePath(env)
|
|
1388
|
+
const teamId = (config.linear && config.linear.teamId) || ''
|
|
1389
|
+
const teamKey = (config.linear && config.linear.teamKey) || ''
|
|
1390
|
+
const label = teamKey ? `${teamId} (${teamKey})` : teamId
|
|
1391
|
+
|
|
1392
|
+
if (!teamId) {
|
|
1393
|
+
out.write(
|
|
1394
|
+
'spec-sync credentials: no linear.teamId in specs/.core/linear.config.json.\n' +
|
|
1395
|
+
' The store is keyed by team — run `spec-sync init-config` first.\n',
|
|
1396
|
+
)
|
|
1397
|
+
return 1
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
if (action === 'status' || !action) return credentialsStatus(dir, config, file, label, flags, out)
|
|
1401
|
+
if (action === 'set') return credentialsSet(file, teamId, label, flags, out)
|
|
1402
|
+
if (action === 'unset') return credentialsUnset(file, teamId, label, out)
|
|
1403
|
+
|
|
1404
|
+
out.write('Usage: skitterspec spec-sync credentials <status|set|unset> [--stdin] [--json]\n')
|
|
1405
|
+
return 1
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// Readiness only — the command a skill runs. Never prints the key.
|
|
1409
|
+
function credentialsStatus(dir, config, file, label, flags, out) {
|
|
1410
|
+
const resolved = resolveApiKey(config, flags.env || process.env)
|
|
1411
|
+
const mode = storeMode(file)
|
|
1412
|
+
const present = resolved.ok
|
|
1413
|
+
const payload = {
|
|
1414
|
+
store: file,
|
|
1415
|
+
mode,
|
|
1416
|
+
team: label,
|
|
1417
|
+
key: present ? { present: true, source: resolved.source, fingerprint: fingerprint(resolved.key) } : { present: false },
|
|
1418
|
+
}
|
|
1419
|
+
if (flags.json) {
|
|
1420
|
+
out.write(JSON.stringify(payload, null, 2) + '\n')
|
|
1421
|
+
return present ? 0 : 1
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
const lines = ['spec-sync credentials:']
|
|
1425
|
+
const strayed = repoConfigKeyCommand(dir)
|
|
1426
|
+
if (strayed) {
|
|
1427
|
+
lines.push(
|
|
1428
|
+
' note: a keyCommand in specs/.core/linear.config.json is IGNORED.',
|
|
1429
|
+
' That file is committed, so a command there would run on the',
|
|
1430
|
+
' machine of anyone who cloned the repo. Record it here instead:',
|
|
1431
|
+
' skitterspec spec-sync credentials set --command <cmd>',
|
|
1432
|
+
)
|
|
1433
|
+
}
|
|
1434
|
+
lines.push(` store: ${file}${mode ? ` (${mode})` : ' (not created yet)'}`)
|
|
1435
|
+
lines.push(` team: ${label}`)
|
|
1436
|
+
if (present) {
|
|
1437
|
+
const where =
|
|
1438
|
+
resolved.source === 'env'
|
|
1439
|
+
? `environment (${resolved.envVar})`
|
|
1440
|
+
: resolved.source === 'command'
|
|
1441
|
+
? 'keyCommand'
|
|
1442
|
+
: 'store'
|
|
1443
|
+
lines.push(` key: set — ${fingerprint(resolved.key)} from the ${where}`)
|
|
1444
|
+
if (resolved.command) lines.push(` runs: ${resolved.command}`)
|
|
1445
|
+
} else {
|
|
1446
|
+
lines.push(' key: not set')
|
|
1447
|
+
// `resolveApiKey` appends a reason when the store or its keyCommand is
|
|
1448
|
+
// broken rather than merely absent. Dropping it here would report a failing
|
|
1449
|
+
// command as "you never set a key" and send the user to set it again.
|
|
1450
|
+
for (const detail of resolved.error.split('\n').slice(1)) {
|
|
1451
|
+
if (detail.trim()) lines.push(` problem:${detail.replace(/^ +/, ' ')}`)
|
|
1452
|
+
}
|
|
1453
|
+
lines.push('')
|
|
1454
|
+
lines.push(' Run this yourself, in your own terminal — not through an assistant:')
|
|
1455
|
+
lines.push(' skitterspec spec-sync credentials set')
|
|
1456
|
+
}
|
|
1457
|
+
out.write(lines.join('\n') + '\n')
|
|
1458
|
+
return present ? 0 : 1
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// The human-facing setter. TTY prompt with echo off, or `--stdin` for a pipe.
|
|
1462
|
+
async function credentialsSet(file, teamId, label, flags, out) {
|
|
1463
|
+
if (flags.keyArgGiven) {
|
|
1464
|
+
out.write(
|
|
1465
|
+
'spec-sync credentials: --key is not supported, on purpose.\n' +
|
|
1466
|
+
' A secret in the command line is visible in shell history and to `ps`.\n' +
|
|
1467
|
+
' Run `credentials set` with no arguments and paste at the prompt (input\n' +
|
|
1468
|
+
' is hidden), or pipe it: `… | credentials set --stdin`.\n',
|
|
1469
|
+
)
|
|
1470
|
+
return 1
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// A command is not a secret — it names WHERE the key lives, so unlike --key it
|
|
1474
|
+
// is safe on the command line and nothing is prompted for.
|
|
1475
|
+
if (flags.command) {
|
|
1476
|
+
const r = writeKeyCommand(file, teamId, flags.command)
|
|
1477
|
+
if (!r.ok) {
|
|
1478
|
+
out.write(`spec-sync credentials: ${r.reason}\n`)
|
|
1479
|
+
return 1
|
|
1480
|
+
}
|
|
1481
|
+
out.write(
|
|
1482
|
+
`spec-sync credentials: ${label} will resolve its key by running:\n` +
|
|
1483
|
+
` ${flags.command}\n` +
|
|
1484
|
+
` recorded in ${r.path} (600)\n`,
|
|
1485
|
+
)
|
|
1486
|
+
return 0
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
let key
|
|
1490
|
+
if (flags.stdin) {
|
|
1491
|
+
key = (await readAllStdin(flags.input || process.stdin)).trim()
|
|
1492
|
+
if (!key) {
|
|
1493
|
+
out.write('spec-sync credentials: nothing on stdin — no key stored.\n')
|
|
1494
|
+
return 1
|
|
1495
|
+
}
|
|
1496
|
+
} else {
|
|
1497
|
+
const input = flags.input || process.stdin
|
|
1498
|
+
if (!input.isTTY) {
|
|
1499
|
+
out.write(
|
|
1500
|
+
'spec-sync credentials: not a terminal — cannot prompt for a key.\n' +
|
|
1501
|
+
' Run it in your own terminal, or pipe the key with --stdin.\n',
|
|
1502
|
+
)
|
|
1503
|
+
return 1
|
|
1504
|
+
}
|
|
1505
|
+
key = (await promptHidden(`Linear personal API key for ${label} (hidden): `, input, out)).trim()
|
|
1506
|
+
if (!key) {
|
|
1507
|
+
out.write('spec-sync credentials: empty input — no key stored.\n')
|
|
1508
|
+
return 1
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
const r = writeKey(file, teamId, key)
|
|
1513
|
+
if (!r.ok) {
|
|
1514
|
+
out.write(`spec-sync credentials: ${r.reason}\n`)
|
|
1515
|
+
return 1
|
|
1516
|
+
}
|
|
1517
|
+
out.write(`spec-sync credentials: key stored for ${label} in ${r.path} (600)\n`)
|
|
1518
|
+
return 0
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
function credentialsUnset(file, teamId, label, out) {
|
|
1522
|
+
const r = removeKey(file, teamId)
|
|
1523
|
+
if (!r.ok) {
|
|
1524
|
+
out.write(`spec-sync credentials: ${r.reason}\n`)
|
|
1525
|
+
return 1
|
|
1526
|
+
}
|
|
1527
|
+
out.write(
|
|
1528
|
+
r.removed
|
|
1529
|
+
? `spec-sync credentials: removed the key for ${label} from ${r.path}\n`
|
|
1530
|
+
: `spec-sync credentials: no key stored for ${label} — nothing to remove\n`,
|
|
1531
|
+
)
|
|
1532
|
+
return 0
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
// Is a keyCommand set in the REPO's committed config? It is never honoured — the
|
|
1536
|
+
// loader drops unknown keys — but silently ignoring it would leave someone
|
|
1537
|
+
// wondering why their command never runs, so `status` calls it out.
|
|
1538
|
+
function repoConfigKeyCommand(dir) {
|
|
1539
|
+
try {
|
|
1540
|
+
const raw = fs.readFileSync(path.join(dir, CONFIG_FILE), 'utf-8')
|
|
1541
|
+
const parsed = JSON.parse(raw)
|
|
1542
|
+
const auth = parsed && parsed.auth
|
|
1543
|
+
return auth && typeof auth.keyCommand === 'string' && auth.keyCommand.trim()
|
|
1544
|
+
? auth.keyCommand.trim()
|
|
1545
|
+
: null
|
|
1546
|
+
} catch {
|
|
1547
|
+
return null
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// Read stdin to completion (for `--stdin`).
|
|
1552
|
+
function readAllStdin(input) {
|
|
1553
|
+
return new Promise((resolve, reject) => {
|
|
1554
|
+
let data = ''
|
|
1555
|
+
input.setEncoding('utf-8')
|
|
1556
|
+
input.on('data', (chunk) => (data += chunk))
|
|
1557
|
+
input.on('end', () => resolve(data))
|
|
1558
|
+
input.on('error', reject)
|
|
1559
|
+
})
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
// Prompt on a TTY with the input hidden. `_writeToOutput` is readline's own echo
|
|
1563
|
+
// hook — silencing it is what keeps the key off the screen (and out of a
|
|
1564
|
+
// screen-shared terminal or a recorded session).
|
|
1565
|
+
function promptHidden(question, input, out) {
|
|
1566
|
+
const readline = require('node:readline')
|
|
1567
|
+
return new Promise((resolve) => {
|
|
1568
|
+
const rl = readline.createInterface({ input, output: process.stdout, terminal: true })
|
|
1569
|
+
out.write(question)
|
|
1570
|
+
rl.question('', (answer) => {
|
|
1571
|
+
out.write('\n')
|
|
1572
|
+
rl.close()
|
|
1573
|
+
resolve(answer)
|
|
1574
|
+
})
|
|
1575
|
+
rl._writeToOutput = () => {}
|
|
1576
|
+
})
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1169
1579
|
async function specSync(rest, io = {}) {
|
|
1170
1580
|
const out = io.out || process.stdout
|
|
1171
1581
|
const err = io.err || process.stderr
|
|
@@ -1173,7 +1583,7 @@ async function specSync(rest, io = {}) {
|
|
|
1173
1583
|
let dir = io.cwd || process.cwd()
|
|
1174
1584
|
const positional = []
|
|
1175
1585
|
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 }
|
|
1586
|
+
force: false, write: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null }
|
|
1177
1587
|
for (let i = 0; i < args.length; i++) {
|
|
1178
1588
|
if (args[i] === '--dir') dir = path.resolve(args[++i])
|
|
1179
1589
|
else if (args[i] === '--json') flags.json = true
|
|
@@ -1189,6 +1599,17 @@ async function specSync(rest, io = {}) {
|
|
|
1189
1599
|
else if (args[i] === '--url') flags.url = args[++i]
|
|
1190
1600
|
else if (args[i] === '--sub') flags.subs.push(args[++i])
|
|
1191
1601
|
else if (args[i] === '--force') flags.force = true
|
|
1602
|
+
else if (args[i] === '--write') flags.write = true
|
|
1603
|
+
else if (args[i] === '--stdin') flags.stdin = true
|
|
1604
|
+
else if (args[i] === '--command') flags.command = String(args[++i] || '').trim()
|
|
1605
|
+
else if (args[i] === '--key') {
|
|
1606
|
+
// Consumed and DELIBERATELY DISCARDED. A secret in argv is visible in
|
|
1607
|
+
// shell history and to `ps`, so this is refused rather than supported —
|
|
1608
|
+
// but it must still be swallowed here, or the value would fall through to
|
|
1609
|
+
// `positional` and end up printed back in a usage message.
|
|
1610
|
+
i++
|
|
1611
|
+
flags.keyArgGiven = true
|
|
1612
|
+
}
|
|
1192
1613
|
else if (args[i] === '--team-id') flags.teamId = String(args[++i] || '').trim()
|
|
1193
1614
|
else if (args[i] === '--team-key') flags.teamKey = String(args[++i] || '').trim()
|
|
1194
1615
|
else if (args[i] === '--project-id') flags.projectId = String(args[++i] || '').trim()
|
|
@@ -1227,21 +1648,21 @@ async function specSync(rest, io = {}) {
|
|
|
1227
1648
|
|
|
1228
1649
|
switch (sub) {
|
|
1229
1650
|
case 'normalize':
|
|
1230
|
-
specSyncNormalize(dir, config, positional[0], out, err)
|
|
1231
|
-
return 0
|
|
1651
|
+
return specSyncNormalize(dir, config, positional[0], out, err) || 0
|
|
1232
1652
|
case 'push':
|
|
1233
1653
|
return specSyncPush(dir, config, positional[0], flags, out, err) || 0
|
|
1234
1654
|
case 'stamp':
|
|
1235
1655
|
return specSyncStamp(dir, config, positional[0], flags, out)
|
|
1236
1656
|
case 'record':
|
|
1237
|
-
specSyncRecord(dir, config, positional[0], out)
|
|
1238
|
-
return 0
|
|
1657
|
+
return specSyncRecord(dir, config, positional[0], out) || 0
|
|
1239
1658
|
case 'status':
|
|
1240
1659
|
return specSyncStatus(dir, config, positional[0], flags, out) || 0
|
|
1241
1660
|
case 'projects':
|
|
1242
1661
|
return (await specSyncProjects(dir, config, flags, out)) || 0
|
|
1243
1662
|
case 'states':
|
|
1244
1663
|
return (await specSyncStates(dir, config, flags, out)) || 0
|
|
1664
|
+
case 'doctor':
|
|
1665
|
+
return (await specSyncDoctor(dir, config, flags, out)) || 0
|
|
1245
1666
|
case 'apply':
|
|
1246
1667
|
return (await specSyncApply(dir, config, positional[0], flags, out)) || 0
|
|
1247
1668
|
case 'verify':
|
|
@@ -1249,8 +1670,11 @@ async function specSync(rest, io = {}) {
|
|
|
1249
1670
|
case 'linked':
|
|
1250
1671
|
specSyncLinked(dir, config, flags, out)
|
|
1251
1672
|
return 0
|
|
1673
|
+
case 'credentials':
|
|
1674
|
+
return await specSyncCredentials(dir, config, positional[0], flags, out)
|
|
1252
1675
|
default:
|
|
1253
1676
|
out.write('Usage: skitterspec spec-sync <normalize|record|status> <spec> [--json] [--remote file] [--workspace-states file]\n' +
|
|
1677
|
+
' skitterspec spec-sync credentials <status|set|unset> [--stdin] [--json]\n' +
|
|
1254
1678
|
' skitterspec spec-sync push <spec> --workspace-states <file> [--json] [--skip-state-check]\n' +
|
|
1255
1679
|
' skitterspec spec-sync stamp <spec> --issue KEY-1 [--url URL] [--sub <ref>=KEY-2 …]\n' +
|
|
1256
1680
|
' skitterspec spec-sync states [--via api|mcp] [--json]\n' +
|
|
@@ -1259,6 +1683,7 @@ async function specSync(rest, io = {}) {
|
|
|
1259
1683
|
' skitterspec spec-sync apply --all <bucket> [--via api|mcp] [--json]\n' +
|
|
1260
1684
|
' skitterspec spec-sync verify <spec> --stored <file>\n' +
|
|
1261
1685
|
' skitterspec spec-sync linked [--json]\n' +
|
|
1686
|
+
' skitterspec spec-sync doctor [--write] [--json]\n' +
|
|
1262
1687
|
' skitterspec spec-sync init-config --team-id <id> [--team-key K] [--project-id id]\n' +
|
|
1263
1688
|
' [--intake-label L] [--bug-labels a,b] [--hotfix-labels a,b]\n' +
|
|
1264
1689
|
' [--state <bucket>=<name> …] [--states <file>] [--force] [--json]\n')
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The commands this provider distribution adds on top of the base CLI.
|
|
5
|
+
*
|
|
6
|
+
* ONE table drives both routing and `--help`. That pairing is the point: the bug
|
|
7
|
+
* this fixes was `spec-sync` being routed by the bin while the base's `HELP`
|
|
8
|
+
* const knew nothing about it, so the one distribution that ships the command
|
|
9
|
+
* told users it did not exist. Anything added here is routed and documented in
|
|
10
|
+
* the same edit — the two cannot drift, and a test asserts it.
|
|
11
|
+
*
|
|
12
|
+
* `run(rest)` returns an exit code (the bin propagates it); `summary` is the
|
|
13
|
+
* one-line description `--help` prints.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { specSync } = require('./cli-sync.js')
|
|
17
|
+
const { specSanitise } = require('./cli-sanitise.js')
|
|
18
|
+
|
|
19
|
+
const DIST = '@skitterbyte/skitterspec-linear'
|
|
20
|
+
|
|
21
|
+
const PROVIDER_COMMANDS = {
|
|
22
|
+
'spec-sync': {
|
|
23
|
+
run: specSync,
|
|
24
|
+
usage: 'skitterspec spec-sync <cmd>',
|
|
25
|
+
summary:
|
|
26
|
+
'One-way sync to Linear (repo -> tracker; opt-in, needs\n' +
|
|
27
|
+
'specs/.core/linear.config.json). Run it with no args to\n' +
|
|
28
|
+
'list its subcommands.',
|
|
29
|
+
},
|
|
30
|
+
'spec-sanitise': {
|
|
31
|
+
run: specSanitise,
|
|
32
|
+
usage: 'skitterspec spec-sanitise',
|
|
33
|
+
summary:
|
|
34
|
+
'Rewrite spec markdown so no emphasis or link straddles a\n' +
|
|
35
|
+
'line break. Dry-run; --write to apply.',
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The `--help` section for these commands, in the base HELP's column layout:
|
|
40
|
+
// two-space indent, description starting at column 30, continuations aligned.
|
|
41
|
+
const COL = 30
|
|
42
|
+
|
|
43
|
+
function providerHelpSection() {
|
|
44
|
+
const lines = [`Provider commands (${DIST}):`]
|
|
45
|
+
for (const name of Object.keys(PROVIDER_COMMANDS)) {
|
|
46
|
+
const { usage, summary } = PROVIDER_COMMANDS[name]
|
|
47
|
+
const [first, ...rest] = summary.split('\n')
|
|
48
|
+
lines.push(` ${usage.padEnd(COL - 2)}${first}`)
|
|
49
|
+
for (const line of rest) lines.push(`${' '.repeat(COL)}${line}`)
|
|
50
|
+
}
|
|
51
|
+
return lines.join('\n') + '\n'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { PROVIDER_COMMANDS, providerHelpSection, DIST }
|