@skitterbyte/skitterspec-linear 10.3.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/package.json +1 -1
- package/src/init.js +15 -5
- package/src/vendor/linear/api.js +62 -8
- package/src/vendor/linear/cli-sync.js +422 -2
- 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.
|
|
@@ -494,6 +503,26 @@ function specSyncVerify(dir, config, specArg, flags, out) {
|
|
|
494
503
|
)
|
|
495
504
|
return 1
|
|
496
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
|
+
}
|
|
497
526
|
let stored
|
|
498
527
|
try {
|
|
499
528
|
stored = JSON.parse(fs.readFileSync(flags.stored, 'utf-8'))
|
|
@@ -550,6 +579,160 @@ function verifyLines(snapshotDir, config, stored, identifier) {
|
|
|
550
579
|
return lines
|
|
551
580
|
}
|
|
552
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
|
+
|
|
553
736
|
/**
|
|
554
737
|
* `spec-sync states [--json]` — which transport this repo will use, and on the
|
|
555
738
|
* API path the workspace's issue state NAMES.
|
|
@@ -746,13 +929,22 @@ async function applyOneSpec({ dir, config, snapshotDir, plan, adapter, teamId, p
|
|
|
746
929
|
}
|
|
747
930
|
|
|
748
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
|
+
}
|
|
749
941
|
for (const sub of (plan.subIssues && plan.subIssues.update) || []) {
|
|
750
942
|
await adapter.updateIssue(sub.id, withoutNull({
|
|
751
943
|
title: sub.name,
|
|
752
944
|
description: sub.goal,
|
|
753
945
|
stateId: stateId(sub.state),
|
|
754
946
|
}))
|
|
755
|
-
result.subIssues[sub.ref || sub.id] = sub.id
|
|
947
|
+
result.subIssues[sub.ref || refById.get(String(sub.id)) || sub.id] = sub.id
|
|
756
948
|
lines.push(` sub-issue updated: ${sub.id}`)
|
|
757
949
|
}
|
|
758
950
|
|
|
@@ -1173,6 +1365,217 @@ function withoutNull(obj) {
|
|
|
1173
1365
|
return out
|
|
1174
1366
|
}
|
|
1175
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
|
+
|
|
1176
1579
|
async function specSync(rest, io = {}) {
|
|
1177
1580
|
const out = io.out || process.stdout
|
|
1178
1581
|
const err = io.err || process.stderr
|
|
@@ -1180,7 +1583,7 @@ async function specSync(rest, io = {}) {
|
|
|
1180
1583
|
let dir = io.cwd || process.cwd()
|
|
1181
1584
|
const positional = []
|
|
1182
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,
|
|
1183
|
-
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 }
|
|
1184
1587
|
for (let i = 0; i < args.length; i++) {
|
|
1185
1588
|
if (args[i] === '--dir') dir = path.resolve(args[++i])
|
|
1186
1589
|
else if (args[i] === '--json') flags.json = true
|
|
@@ -1196,6 +1599,17 @@ async function specSync(rest, io = {}) {
|
|
|
1196
1599
|
else if (args[i] === '--url') flags.url = args[++i]
|
|
1197
1600
|
else if (args[i] === '--sub') flags.subs.push(args[++i])
|
|
1198
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
|
+
}
|
|
1199
1613
|
else if (args[i] === '--team-id') flags.teamId = String(args[++i] || '').trim()
|
|
1200
1614
|
else if (args[i] === '--team-key') flags.teamKey = String(args[++i] || '').trim()
|
|
1201
1615
|
else if (args[i] === '--project-id') flags.projectId = String(args[++i] || '').trim()
|
|
@@ -1247,6 +1661,8 @@ async function specSync(rest, io = {}) {
|
|
|
1247
1661
|
return (await specSyncProjects(dir, config, flags, out)) || 0
|
|
1248
1662
|
case 'states':
|
|
1249
1663
|
return (await specSyncStates(dir, config, flags, out)) || 0
|
|
1664
|
+
case 'doctor':
|
|
1665
|
+
return (await specSyncDoctor(dir, config, flags, out)) || 0
|
|
1250
1666
|
case 'apply':
|
|
1251
1667
|
return (await specSyncApply(dir, config, positional[0], flags, out)) || 0
|
|
1252
1668
|
case 'verify':
|
|
@@ -1254,8 +1670,11 @@ async function specSync(rest, io = {}) {
|
|
|
1254
1670
|
case 'linked':
|
|
1255
1671
|
specSyncLinked(dir, config, flags, out)
|
|
1256
1672
|
return 0
|
|
1673
|
+
case 'credentials':
|
|
1674
|
+
return await specSyncCredentials(dir, config, positional[0], flags, out)
|
|
1257
1675
|
default:
|
|
1258
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' +
|
|
1259
1678
|
' skitterspec spec-sync push <spec> --workspace-states <file> [--json] [--skip-state-check]\n' +
|
|
1260
1679
|
' skitterspec spec-sync stamp <spec> --issue KEY-1 [--url URL] [--sub <ref>=KEY-2 …]\n' +
|
|
1261
1680
|
' skitterspec spec-sync states [--via api|mcp] [--json]\n' +
|
|
@@ -1264,6 +1683,7 @@ async function specSync(rest, io = {}) {
|
|
|
1264
1683
|
' skitterspec spec-sync apply --all <bucket> [--via api|mcp] [--json]\n' +
|
|
1265
1684
|
' skitterspec spec-sync verify <spec> --stored <file>\n' +
|
|
1266
1685
|
' skitterspec spec-sync linked [--json]\n' +
|
|
1686
|
+
' skitterspec spec-sync doctor [--write] [--json]\n' +
|
|
1267
1687
|
' skitterspec spec-sync init-config --team-id <id> [--team-key K] [--project-id id]\n' +
|
|
1268
1688
|
' [--intake-label L] [--bug-labels a,b] [--hotfix-labels a,b]\n' +
|
|
1269
1689
|
' [--state <bucket>=<name> …] [--states <file>] [--force] [--json]\n')
|