singular-skill 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/refs.js ADDED
@@ -0,0 +1,101 @@
1
+ // lib/refs.js — skill references: owner/repo@skill and owner/repo#<40-hex-sha>@skill
2
+ //
3
+ // REF_RE strict grammar (anchored)
4
+ // parseRef(str) -> {owner, repo, sha|null, skill} | null
5
+ // formatRef(ref) -> "owner/repo#sha@skill" (sha omitted when null)
6
+ // unpinnedRef(ref) -> "owner/repo@skill"
7
+ // shortSha(sha) -> first 7 chars
8
+ // isSha(s) -> 40 lowercase hex
9
+ // normalizeRefCase(ref) -> same ref with owner/repo lowercased (GitHub is case-insensitive there; the skill is not)
10
+ // resolveHeadSha(owner, repo, {timeoutMs}, deps) -> sha via `git ls-remote https://github.com/o/r HEAD`
11
+ // (the base is the SINGULAR_GIT_BASE test seam when set, see lib/git.js)
12
+ //
13
+ // Owner/repo/skill are validated by REF_RE before they ever reach an argv array;
14
+ // git runs through execFile with an args array (no shell), GIT_TERMINAL_PROMPT=0 and the allowlisted child
15
+ // environment of lib/git.js childEnv (no inherited GIT_CONFIG_* / proxy-command / ssh-command variables).
16
+ //
17
+ // Grammar (tightened 2026-09-24): every segment must START with an alphanumeric, so a skill or repo
18
+ // named "-y" / "--skill" can never be read as a CLI flag by a subprocess, and "." / ".." can never be
19
+ // a path segment. GitHub applies the same first-character rule to owners and repos.
20
+
21
+ import { execFile as nodeExecFile } from 'node:child_process';
22
+
23
+ import { childEnv, describeGitFailure, repoUrl, runExecFile } from './git.js';
24
+
25
+ export const OWNER_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/;
26
+ export const REPO_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
27
+ export const SKILL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
28
+ export const REF_RE = /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9][A-Za-z0-9._-]*(#[0-9a-f]{40})?@[A-Za-z0-9][A-Za-z0-9._-]*$/;
29
+ const PARTS_RE = /^([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)(?:#([0-9a-f]{40}))?@([A-Za-z0-9][A-Za-z0-9._-]*)$/;
30
+ const SHA_RE = /^[0-9a-f]{40}$/;
31
+ const MAX_SEGMENT = 100;
32
+
33
+ /** A segment is safe as a path component and as an argv element: starts alphanumeric, not "." / "..", bounded length. */
34
+ export function isSafeSegment(s, re = SKILL_RE) {
35
+ const v = String(s ?? '');
36
+ return v.length > 0 && v.length <= MAX_SEGMENT && re.test(v) && v !== '.' && v !== '..' && !v.startsWith('-');
37
+ }
38
+
39
+ export function isSha(s) {
40
+ return typeof s === 'string' && SHA_RE.test(s);
41
+ }
42
+
43
+ export function shortSha(sha) {
44
+ return isSha(sha) ? sha.slice(0, 7) : '';
45
+ }
46
+
47
+ export function parseRef(str) {
48
+ const m = PARTS_RE.exec(String(str ?? '').trim());
49
+ if (!m) return null;
50
+ if (!isSafeSegment(m[1], OWNER_RE) || !isSafeSegment(m[2], REPO_RE) || !isSafeSegment(m[4], SKILL_RE)) return null;
51
+ return { owner: m[1], repo: m[2], sha: m[3] ?? null, skill: m[4] };
52
+ }
53
+
54
+ export function formatRef(ref) {
55
+ if (!ref || typeof ref !== 'object') return '';
56
+ const sha = ref.sha ? `#${ref.sha}` : '';
57
+ return `${ref.owner}/${ref.repo}${sha}@${ref.skill}`;
58
+ }
59
+
60
+ export function unpinnedRef(ref) {
61
+ const r = typeof ref === 'string' ? parseRef(ref) : ref;
62
+ if (!r) return '';
63
+ return `${r.owner}/${r.repo}@${r.skill}`;
64
+ }
65
+
66
+ /**
67
+ * Lowercase the owner and repo segments (GitHub resolves them case-insensitively, so `MULTICA-AI/x@s`
68
+ * and `multica-ai/x@s` are the same source and must share one pin and one text-cache entry); the skill
69
+ * segment is kept as typed because /singular add matches it case-sensitively.
70
+ */
71
+ export function normalizeRefCase(ref) {
72
+ const r = typeof ref === 'string' ? parseRef(ref) : ref;
73
+ if (!r || typeof r !== 'object') return null;
74
+ return { ...r, owner: String(r.owner).toLowerCase(), repo: String(r.repo).toLowerCase() };
75
+ }
76
+
77
+ /**
78
+ * Resolve the current HEAD commit of https://github.com/<owner>/<repo>.
79
+ * deps.execFileImpl / deps.env are injectable for tests.
80
+ */
81
+ export async function resolveHeadSha(owner, repo, opts = {}, deps = {}) {
82
+ const { timeoutMs = 8000 } = opts;
83
+ const execFileImpl = deps.execFileImpl ?? nodeExecFile;
84
+ const env = deps.env ?? process.env;
85
+ if (!isSafeSegment(owner, OWNER_RE) || !isSafeSegment(repo, REPO_RE)) {
86
+ throw new Error(`invalid owner/repo: ${owner}/${repo}`);
87
+ }
88
+ const url = repoUrl(owner, repo, env);
89
+ const r = await runExecFile(execFileImpl, 'git', ['ls-remote', url, 'HEAD'], {
90
+ env: childEnv(env),
91
+ timeout: Math.max(1, Math.floor(Number(timeoutMs) || 8000)),
92
+ killSignal: 'SIGKILL',
93
+ maxBuffer: 1024 * 1024,
94
+ windowsHide: true,
95
+ });
96
+ if (r.killed || r.signal) throw new Error(`git ls-remote ${url} timed out after ${timeoutMs}ms`);
97
+ if (r.error || r.code !== 0) throw new Error(describeGitFailure(`git ls-remote ${url}`, r, timeoutMs));
98
+ const m = /^([0-9a-f]{40})\s+HEAD\b/m.exec(r.stdout);
99
+ if (!m) throw new Error(`could not parse HEAD sha from git ls-remote output for ${url}`);
100
+ return m[1];
101
+ }