skillshark 0.2.0 → 0.3.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/README.md CHANGED
@@ -36,13 +36,15 @@ Requirements: Node ≥ 20. **Senders** also need the [GitHub CLI](https://cli.gi
36
36
 
37
37
  ## The four commands
38
38
 
39
- **share** — package a skill and get an unlisted link (auto-copied to your clipboard):
39
+ **share** — package a skill and get an unlisted link. Your clipboard receives the full **paste-and-go install one-liner**, so the receiver just pastes it into any terminal:
40
40
 
41
41
  ```sh
42
42
  skillshark share /j
43
- # → https://gist.github.com/8a1bc94ef23d4b6a9c01e57f8d2a4b3c#fp=3f9a7c21
43
+ # clipboard npx skillshark install 'https://gist.github.com/8a1bc94…#fp=3f9a7c21'
44
44
  ```
45
45
 
46
+ (`-q` still prints the bare URL for scripts; `--json` includes both `url` and `installCommand`.)
47
+
46
48
  Accepts a name (`j`, `/j` — resolved across `./.claude/skills`, `./.claude/commands`, and their `~/` equivalents) or any path. Useful flags: `--expires 30m|6h|24h|7d|30d` (advisory, default 7d), `--dry-run`, `--name`, `--force`, `--no-clipboard`, `-q` (print only the URL).
47
49
 
48
50
  **install** — download, verify, preview, confirm, copy:
@@ -102,6 +104,23 @@ Crossing agents **converts** the artifact: the instructions (frontmatter + body)
102
104
 
103
105
  Same-agent installs are always byte-verbatim — conversion only happens when you cross.
104
106
 
107
+ ## GitHub Enterprise (v0.3)
108
+
109
+ If your company runs GitHub Enterprise (GHES or `*.ghe.com`), SkillShark works entirely inside it — nothing touches public github.com:
110
+
111
+ ```sh
112
+ gh auth login --hostname ghe.corp.com # once, sender and receivers alike
113
+ skillshark share j --host ghe.corp.com # gist lives on YOUR GitHub
114
+ skillshark install 'https://ghe.corp.com/gist/<id>#fp=<hex>'
115
+ ```
116
+
117
+ - **Links carry their host.** Receivers don't need `--host` — an enterprise URL routes itself. `GH_HOST` (or `SKILLSHARK_HOST`) sets the default for bare ids and `gh:owner/repo` sources.
118
+ - **The privacy property:** enterprise links are fetched exclusively through the receiver's own `gh` auth. No anonymous request ever leaves for an enterprise host (enforced by tests), and unauthenticated users get a clear `gh auth login --hostname …` pointer instead of a leak.
119
+ - **One honest limit:** the gists API truncates inline content at ~1 MB and enterprise receivers can't fetch around it anonymously, so enterprise *gist* shares are capped at ~900 KB encoded. Bigger skills: put them in a repo on your GHES and share `gh:owner/repo/path` with `--host`.
120
+ - `revoke` remembers which host a share went to and deletes it there.
121
+
122
+ Public github.com behavior is completely unchanged: receivers still need no account and the receive path still never invokes `gh`.
123
+
105
124
  ## Security model
106
125
 
107
126
  - **SkillShark never executes package content.** Install = copy files. No postinstall hooks, no scripts, ever.
package/bin/skillshark.js CHANGED
@@ -33,10 +33,18 @@ GLOBAL OPTIONS
33
33
  -y, --yes Skip prompts (non-interactive)
34
34
  -q, --quiet Print only the essential result (URL or path)
35
35
  --json Machine-readable output
36
+ --host <h> GitHub Enterprise hostname (default github.com; also
37
+ honors GH_HOST). Enterprise links carry their host, so
38
+ receivers usually don't need this flag.
36
39
  --no-color Disable color (NO_COLOR is also honored)
37
40
  -h, --help Show help (try: skillshark help <command>)
38
41
  -V, --version Show version
39
42
 
43
+ ENTERPRISE (privacy)
44
+ skillshark share j --host ghe.corp.com share on your GHES — never leaves it
45
+ Enterprise links are fetched through YOUR gh auth (gh auth login --hostname
46
+ ghe.corp.com); no anonymous request ever touches an enterprise host.
47
+
40
48
  EXAMPLES
41
49
  skillshark interactive: menus for everything below
42
50
  skillshark share /j share the "j" skill (secret gist)
@@ -111,6 +119,7 @@ const GLOBAL_FLAGS = {
111
119
  yes: { short: 'y', key: 'yes' },
112
120
  quiet: { short: 'q', key: 'quiet' },
113
121
  json: { key: 'json' },
122
+ host: { takesValue: true, key: 'host' },
114
123
  'no-color': { key: 'noColor' },
115
124
  help: { short: 'h', key: 'help' },
116
125
  version: { short: 'V', key: 'version' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillshark",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Share agent skills like files — secret gists out, safe verified installs in. No server; GitHub is the backend.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/gh.js CHANGED
@@ -1,30 +1,46 @@
1
1
  // The gh helper — the ONLY module (besides the clipboard) that touches
2
- // child_process. Used exclusively by sender operations (share, revoke).
3
- // Receivers never come through here. execFile with argument arrays only;
4
- // user input is never interpolated into a shell string (hard rule 3).
2
+ // child_process. Used by sender operations (share, revoke) and, for GitHub
3
+ // Enterprise hosts, by the receive path too: enterprise links are private by
4
+ // nature, so they ride the receiver's own gh auth never anonymous HTTPS.
5
+ // execFile with argument arrays only; user input is never interpolated into
6
+ // a shell string (hard rule 3).
5
7
  import { execFile as execFileCb } from 'node:child_process';
6
8
  import { promisify } from 'node:util';
7
9
  import { CliError, MSG } from './errors.js';
10
+ import { DEFAULT_HOST } from './source.js';
8
11
 
9
12
  const execFileP = promisify(execFileCb);
10
13
 
11
14
  // Default runner; tests inject their own to capture or forbid calls.
12
- export async function defaultGhRunner(args) {
13
- const { stdout } = await execFileP('gh', args, { maxBuffer: 32 * 1024 * 1024 });
15
+ // opts.binary returns a Buffer (repo tarballs); default is utf8 text.
16
+ export async function defaultGhRunner(args, opts = {}) {
17
+ const { stdout } = await execFileP('gh', args, {
18
+ maxBuffer: 96 * 1024 * 1024,
19
+ encoding: opts.binary ? 'buffer' : 'utf8',
20
+ });
14
21
  return stdout;
15
22
  }
16
23
 
24
+ // Extra args to aim gh at a GitHub Enterprise host.
25
+ export function hostArgs(host) {
26
+ return host && host !== DEFAULT_HOST ? ['--hostname', host] : [];
27
+ }
28
+
17
29
  // Run `gh api ...` and map the usual failure modes onto exit-code-2 guidance.
18
30
  export function makeGhApi(runner = defaultGhRunner) {
19
- return async function ghApi(args) {
31
+ return async function ghApi(args, opts = {}) {
20
32
  try {
21
- return await runner(['api', ...args]);
33
+ return await runner(['api', ...args], opts);
22
34
  } catch (err) {
23
35
  if (err instanceof CliError) throw err;
24
- if (err?.code === 'ENOENT') throw new CliError(MSG.ghMissing, 2);
36
+ const hostIdx = args.indexOf('--hostname');
37
+ const host = hostIdx !== -1 ? args[hostIdx + 1] : null;
38
+ if (err?.code === 'ENOENT') {
39
+ throw new CliError(host ? enterpriseGhMsg(host) : MSG.ghMissing, 2);
40
+ }
25
41
  const stderr = String(err?.stderr ?? '');
26
42
  if (/not logged in|authentication|HTTP 401|gh auth login/i.test(stderr)) {
27
- throw new CliError(MSG.ghMissing, 2);
43
+ throw new CliError(host ? enterpriseGhMsg(host) : MSG.ghMissing, 2);
28
44
  }
29
45
  if (/HTTP 404/.test(stderr)) throw new CliError('GitHub returned 404 for that id.', 1);
30
46
  const detail = stderr.trim().split('\n')[0] || err.message;
@@ -32,3 +48,10 @@ export function makeGhApi(runner = defaultGhRunner) {
32
48
  }
33
49
  };
34
50
  }
51
+
52
+ export function enterpriseGhMsg(host) {
53
+ return (
54
+ `This needs the GitHub CLI authenticated against ${host} (GitHub Enterprise):\n` +
55
+ ` https://cli.github.com, then "gh auth login --hostname ${host}"`
56
+ );
57
+ }
package/src/install.js CHANGED
@@ -9,7 +9,7 @@ import { readFileSync, existsSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import os from 'node:os';
11
11
  import { CliError, MSG } from './errors.js';
12
- import { parseSource, formatSource } from './source.js';
12
+ import { parseSource, formatSource, resolveHost } from './source.js';
13
13
  import { fetchGistPackage } from './transports/gist.js';
14
14
  import { fetchRepoTree } from './transports/repo.js';
15
15
  import { extractTarball, hashTree, readManifest, verifyTreeAgainstManifest, MANIFEST_NAME } from './pkg.js';
@@ -35,12 +35,13 @@ function classifyRepoTree(actual, subPath, repo) {
35
35
  return { type: 'bundle', agent: '' };
36
36
  }
37
37
 
38
- export async function fetchAndVerify(sourceStr, deps) {
39
- const src = parseSource(sourceStr);
38
+ export async function fetchAndVerify(sourceStr, deps, opts = {}) {
39
+ const defaultHost = resolveHost(opts, deps);
40
+ const src = parseSource(sourceStr, { defaultHost });
40
41
  const workDir = await mkdtemp(path.join(os.tmpdir(), 'skillshark-recv-'));
41
42
 
42
43
  if (src.kind === 'gist') {
43
- const gist = await fetchGistPackage(src.id, { fetch: deps.fetch });
44
+ const gist = await fetchGistPackage(src.id, { fetch: deps.fetch, host: src.host, ghApi: deps.ghApi });
44
45
  await extractTarball(gist.tarball, workDir);
45
46
  const manifest = await readManifest(workDir);
46
47
  const actual = await hashTree(workDir);
@@ -55,13 +56,13 @@ export async function fetchAndVerify(sourceStr, deps) {
55
56
  fingerprint,
56
57
  sender: gist.owner,
57
58
  fpVerified: Boolean(src.fp),
58
- sourceRecord: `gist:${src.id}@${gist.revision ?? 'unknown'}`,
59
+ sourceRecord: `${formatSource({ ...src })}@${gist.revision ?? 'unknown'}`,
59
60
  };
60
61
  }
61
62
 
62
63
  // repo: no manifest exists; run the share-side inference on the extracted
63
64
  // tree and synthesize one in memory. The commit SHA is the integrity.
64
- const { sha } = await fetchRepoTree(src, workDir, { fetch: deps.fetch });
65
+ const { sha } = await fetchRepoTree(src, workDir, { fetch: deps.fetch, ghApi: deps.ghApi });
65
66
  const actual = await hashTree(workDir, { exclude: [] });
66
67
  if (actual.length === 0) {
67
68
  throw new CliError(`No files found at gh:${src.owner}/${src.repo}${src.path ? `/${src.path}` : ''}@${sha.slice(0, 7)}.`, 1);
@@ -379,7 +380,7 @@ export async function runInstall(sourceStr, opts, deps) {
379
380
  const interactive = deps.isTTY && !opts.yes;
380
381
  const loud = !opts.json && !opts.quiet;
381
382
  const ui = deps.ui;
382
- const verified = await ui.spin('Fetching and verifying the package', () => fetchAndVerify(sourceStr, deps));
383
+ const verified = await ui.spin('Fetching and verifying the package', () => fetchAndVerify(sourceStr, deps, opts));
383
384
  const { workDir, manifest, fingerprint, sourceRecord } = verified;
384
385
 
385
386
  try {
@@ -615,8 +616,8 @@ function installTargetsLine(manifest) {
615
616
  export async function runInspect(sourceStr, opts, deps) {
616
617
  const ui = deps.ui;
617
618
  const verified = await (opts.json || opts.files
618
- ? fetchAndVerify(sourceStr, deps)
619
- : ui.spin('Fetching and verifying the package', () => fetchAndVerify(sourceStr, deps)));
619
+ ? fetchAndVerify(sourceStr, deps, opts)
620
+ : ui.spin('Fetching and verifying the package', () => fetchAndVerify(sourceStr, deps, opts)));
620
621
  const { workDir, manifest } = verified;
621
622
  try {
622
623
  if (opts.json) {
package/src/share.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { CliError } from './errors.js';
6
+ import { resolveHost, DEFAULT_HOST } from './source.js';
6
7
  import { resolveShareArg, collectFiles, inferMetadata, findExternalRefs } from './discover.js';
7
8
  import { buildTarball } from './pkg.js';
8
9
  import { treeFingerprint, fp8 } from './fingerprint.js';
@@ -153,15 +154,22 @@ export async function runShare(arg, opts, deps) {
153
154
  } catch { /* preview is optional */ }
154
155
  }
155
156
 
156
- const { id, revision } = await ui.spin('Uploading as a secret gist', () =>
157
+ const host = resolveHost(opts, deps);
158
+ const { id, revision, htmlUrl } = await ui.spin('Uploading as a secret gist', () =>
157
159
  createGist({
158
160
  manifestJson,
159
161
  primaryDoc,
160
162
  tarballB64: b64,
161
163
  description: gistDescription({ name: manifest.name, agent: manifest.agent, type: manifest.type, fp8: shortFp }),
162
164
  ghApi: deps.ghApi,
165
+ host,
163
166
  }));
164
- const url = `https://gist.github.com/${id}#fp=${shortFp}`;
167
+ // github.com gets the short canonical form; enterprise hosts keep the
168
+ // html_url GitHub handed back (subdomain isolation varies per install)
169
+ const base = host === DEFAULT_HOST ? `https://gist.github.com/${id}` : (htmlUrl ?? `https://${host}/gist/${id}`);
170
+ const url = `${base}#fp=${shortFp}`;
171
+ // the paste-and-go line: receivers run this with zero setup
172
+ const installCommand = `npx skillshark install '${url}'`;
165
173
 
166
174
  await addShareRecord(deps.configDir, {
167
175
  id,
@@ -169,37 +177,41 @@ export async function runShare(arg, opts, deps) {
169
177
  url,
170
178
  revision,
171
179
  expiresAt: manifest.expiresAt,
180
+ ...(host !== DEFAULT_HOST ? { host } : {}),
172
181
  });
173
182
 
174
183
  let copied = false;
175
184
  if (!opts.noClipboard && deps.clipboard) {
176
- copied = await deps.clipboard(url);
185
+ copied = await deps.clipboard(installCommand);
177
186
  }
178
187
 
179
188
  if (opts.json) {
180
189
  ui.out(JSON.stringify({
181
190
  id,
182
191
  url,
192
+ installCommand,
183
193
  revision,
184
194
  expiresAt: manifest.expiresAt,
185
195
  fingerprint,
186
196
  size: manifest.totalSize,
187
197
  files: manifest.files.map((f) => f.path),
198
+ ...(host !== DEFAULT_HOST ? { host } : {}),
188
199
  }));
189
200
  } else if (opts.quiet) {
190
201
  ui.out(url);
191
202
  } else {
192
203
  ui.out('');
193
204
  ui.ok('Uploaded as a secret gist (unlisted — anyone with the link can read it)');
194
- if (copied) ui.ok(`Link copied to clipboard · advisory expiry in ${expires.label}`);
205
+ if (copied) ui.ok(`Install one-liner copied to clipboard — they just paste it · advisory expiry in ${expires.label}`);
195
206
  else ui.out(` Advisory expiry in ${expires.label}`);
196
207
  ui.out('');
197
- ui.out(` ${url}`);
208
+ ui.out(` ${installCommand}`);
198
209
  ui.out('');
199
- ui.out(' They run: skillshark install <the link> (no GitHub account needed)');
200
- ui.out(` Undo: skillshark revoke ${manifest.name} (deletes the gist)`);
210
+ const who = host === DEFAULT_HOST ? '(no GitHub account needed)' : `(needs gh auth on ${host})`;
211
+ ui.out(` Link only: ${url} ${who}`);
212
+ ui.out(` Undo: skillshark revoke ${manifest.name} (deletes the gist)`);
201
213
  }
202
- return { status: 'shared', id, url, fingerprint };
214
+ return { status: 'shared', id, url, installCommand, fingerprint };
203
215
  }
204
216
 
205
217
  // --- revoke (§4.4) -----------------------------------------------------------
@@ -208,6 +220,7 @@ export async function runRevoke(idOrName, opts, deps) {
208
220
  const ui = deps.ui;
209
221
  let id = null;
210
222
  let label = idOrName;
223
+ let host = resolveHost(opts, deps);
211
224
  if (/^[0-9a-f]{20,32}$/.test(idOrName)) {
212
225
  id = idOrName;
213
226
  } else {
@@ -215,9 +228,14 @@ export async function runRevoke(idOrName, opts, deps) {
215
228
  if (rec) {
216
229
  id = rec.id;
217
230
  label = `${rec.name} (${rec.id})`;
231
+ if (rec.host) host = rec.host; // enterprise share → revoke on its host
218
232
  } else {
219
- // cache miss → ask gh for our skillshark gists
220
- const out = await deps.ghApi(['gists', '--paginate']);
233
+ // cache miss → ask gh for our skillshark gists (on the chosen host)
234
+ const out = await deps.ghApi([
235
+ ...(host !== DEFAULT_HOST ? ['--hostname', host] : []),
236
+ 'gists',
237
+ '--paginate',
238
+ ]);
221
239
  let gists;
222
240
  try {
223
241
  gists = JSON.parse(out);
@@ -242,7 +260,7 @@ export async function runRevoke(idOrName, opts, deps) {
242
260
  return { status: 'cancelled' };
243
261
  }
244
262
  }
245
- await deleteGist(id, { ghApi: deps.ghApi });
263
+ await deleteGist(id, { ghApi: deps.ghApi, host });
246
264
  await removeShareRecord(deps.configDir, id);
247
265
  if (opts.json) ui.out(JSON.stringify({ revoked: id }));
248
266
  else ui.ok(`Revoked — the gist ${id} is gone. Anyone holding the link now gets "deleted by the sender".`);
package/src/source.js CHANGED
@@ -1,29 +1,51 @@
1
1
  // §7.3 — source parsing. Accepted forms:
2
2
  // https://gist.github.com/<id> (optionally <user>/<id>, optionally #fp=<hex>)
3
- // <20-32 char hex gist id>
3
+ // https://gist.<ghes-host>/<id> GitHub Enterprise, subdomain isolation
4
+ // https://<ghes-host>/gist/<id> GitHub Enterprise, path form
5
+ // <20-32 char hex gist id> (host from --host / GH_HOST, default github.com)
4
6
  // gh:owner/repo[/deep/path][@ref] (ref = branch, tag, or SHA; split on the LAST "@")
5
7
  import { CliError } from './errors.js';
6
8
 
9
+ export const DEFAULT_HOST = 'github.com';
10
+
7
11
  const USAGE = `Unrecognized source. Accepted forms:
8
12
  https://gist.github.com/8a1bc94ef23d4b6a9c01e57f8d2a4b3c#fp=3f9a7c21
13
+ https://ghe.example.com/gist/8a1bc94ef23d4b6a9c01e57f8d2a4b3c#fp=3f9a7c21
9
14
  8a1bc94ef23d4b6a9c01e57f8d2a4b3c
10
15
  gh:owner/repo[/deep/path][@ref]`;
11
16
 
12
17
  const NAME_RE = /^[A-Za-z0-9_.-]+$/;
18
+ const HOST_RE = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/i;
19
+
20
+ // The host a command should talk to: explicit flag → env → github.com.
21
+ export function resolveHost(opts = {}, deps = {}) {
22
+ const host = opts.host || deps.env?.SKILLSHARK_HOST || deps.env?.GH_HOST || DEFAULT_HOST;
23
+ if (!HOST_RE.test(host)) throw new CliError(`Invalid --host "${host}".`, 2);
24
+ return host.toLowerCase();
25
+ }
13
26
 
14
- export function parseSource(input) {
27
+ export function parseSource(input, { defaultHost = DEFAULT_HOST } = {}) {
15
28
  const s = String(input ?? '').trim();
16
29
  if (!s) throw new CliError(USAGE, 2);
17
30
 
18
- const gistUrl = s.match(
19
- /^https:\/\/gist\.github\.com\/(?:([A-Za-z0-9-]+)\/)?([0-9a-f]{20,32})\/?(?:#fp=([0-9a-f]{8,64}))?$/,
31
+ // gist.<host>/<id> github.com and GHES-with-subdomain-isolation alike
32
+ let m = s.match(
33
+ /^https:\/\/gist\.([a-z0-9.-]+)\/(?:([A-Za-z0-9-]+)\/)?([0-9a-f]{20,32})\/?(?:#fp=([0-9a-f]{8,64}))?$/i,
34
+ );
35
+ if (m) {
36
+ return { kind: 'gist', id: m[3], fp: m[4] ?? null, host: m[1].toLowerCase() };
37
+ }
38
+
39
+ // <host>/gist/<id> — GHES path form
40
+ m = s.match(
41
+ /^https:\/\/([a-z0-9.-]+)\/gist\/(?:([A-Za-z0-9-]+)\/)?([0-9a-f]{20,32})\/?(?:#fp=([0-9a-f]{8,64}))?$/i,
20
42
  );
21
- if (gistUrl) {
22
- return { kind: 'gist', id: gistUrl[2], fp: gistUrl[3] ?? null };
43
+ if (m && m[1].toLowerCase() !== 'github.com') {
44
+ return { kind: 'gist', id: m[3], fp: m[4] ?? null, host: m[1].toLowerCase() };
23
45
  }
24
46
 
25
47
  if (/^[0-9a-f]{20,32}$/.test(s)) {
26
- return { kind: 'gist', id: s, fp: null };
48
+ return { kind: 'gist', id: s, fp: null, host: defaultHost };
27
49
  }
28
50
 
29
51
  if (s.startsWith('gh:')) {
@@ -43,6 +65,7 @@ export function parseSource(input) {
43
65
  repo: segs[1],
44
66
  path: segs.length > 2 ? segs.slice(2).join('/') : null,
45
67
  ref,
68
+ host: defaultHost,
46
69
  };
47
70
  }
48
71
  }
@@ -51,8 +74,9 @@ export function parseSource(input) {
51
74
  }
52
75
 
53
76
  export function formatSource(src) {
54
- if (src.kind === 'gist') return `gist:${src.id}`;
77
+ const hostTag = src.host && src.host !== DEFAULT_HOST ? `${src.host}:` : '';
78
+ if (src.kind === 'gist') return `gist:${hostTag}${src.id}`;
55
79
  const p = src.path ? `/${src.path}` : '';
56
80
  const r = src.ref ? `@${src.ref}` : '';
57
- return `gh:${src.owner}/${src.repo}${p}${r}`;
81
+ return `gh:${hostTag}${src.owner}/${src.repo}${p}${r}`;
58
82
  }
@@ -1,12 +1,19 @@
1
1
  // Gist transport. Share/revoke go through `gh` (the sender's auth); the
2
- // receive path is ANONYMOUS https only — it must never invoke gh (§0.3).
2
+ // public github.com receive path is ANONYMOUS https only — it must never
3
+ // invoke gh (§0.3). GitHub Enterprise links are the deliberate exception:
4
+ // privacy means everything rides the receiver's own gh auth and no anonymous
5
+ // request ever leaves for the enterprise host.
3
6
  import { mkdtemp, writeFile, rm } from 'node:fs/promises';
4
7
  import path from 'node:path';
5
8
  import os from 'node:os';
6
9
  import { CliError, MSG } from '../errors.js';
7
10
  import { USER_AGENT } from '../version.js';
11
+ import { DEFAULT_HOST } from '../source.js';
8
12
 
9
13
  export const GIST_PAYLOAD_LIMIT = 5 * 1024 * 1024; // encoded bytes (§4.1)
14
+ // the gists API truncates inline file content at ~1 MB; on enterprise hosts we
15
+ // can't fall back to an anonymous raw_url fetch, so shares are capped honestly
16
+ export const ENTERPRISE_INLINE_LIMIT = 900 * 1024;
10
17
  const FETCH_HEADERS = {
11
18
  Accept: 'application/vnd.github+json',
12
19
  'User-Agent': USER_AGENT,
@@ -22,9 +29,19 @@ export function gistDescription({ name, agent, type, fp8 }) {
22
29
  return `skillshark: ${name} (${kind}) · fp ${fp8}`;
23
30
  }
24
31
 
32
+ function hostFlags(host) {
33
+ return host && host !== DEFAULT_HOST ? ['--hostname', host] : [];
34
+ }
35
+
25
36
  // One `gh api gists --method POST --input <tmp.json>` call; a JSON body file
26
37
  // avoids every shell-escaping pitfall (hard rule 3).
27
- export async function createGist({ manifestJson, primaryDoc, tarballB64, description, ghApi }) {
38
+ export async function createGist({ manifestJson, primaryDoc, tarballB64, description, ghApi, host = DEFAULT_HOST }) {
39
+ if (host !== DEFAULT_HOST && tarballB64.length > ENTERPRISE_INLINE_LIMIT) {
40
+ throw new CliError(
41
+ `That's too big for an enterprise gist share (~${Math.floor(ENTERPRISE_INLINE_LIMIT / 1024)} KB encoded cap — the API truncates larger files and enterprise receivers can't fetch around it anonymously). Put it in a repo on ${host} instead.`,
42
+ 2,
43
+ );
44
+ }
28
45
  const files = { 'SKILLSHARK.json': { content: manifestJson } };
29
46
  if (primaryDoc && primaryDoc.content.trim()) {
30
47
  files[path.basename(primaryDoc.name)] = { content: primaryDoc.content };
@@ -36,7 +53,7 @@ export async function createGist({ manifestJson, primaryDoc, tarballB64, descrip
36
53
  const bodyFile = path.join(tmpDir, 'gist-body.json');
37
54
  try {
38
55
  await writeFile(bodyFile, JSON.stringify(body));
39
- const stdout = await ghApi(['gists', '--method', 'POST', '--input', bodyFile]);
56
+ const stdout = await ghApi([...hostFlags(host), 'gists', '--method', 'POST', '--input', bodyFile]);
40
57
  let parsed;
41
58
  try {
42
59
  parsed = JSON.parse(stdout);
@@ -44,14 +61,14 @@ export async function createGist({ manifestJson, primaryDoc, tarballB64, descrip
44
61
  throw new CliError('Unexpected response from gh while creating the gist.', 1);
45
62
  }
46
63
  if (!parsed.id) throw new CliError('gh created no gist (no id in response).', 1);
47
- return { id: parsed.id, revision: parsed.history?.[0]?.version ?? null };
64
+ return { id: parsed.id, revision: parsed.history?.[0]?.version ?? null, htmlUrl: parsed.html_url ?? null };
48
65
  } finally {
49
66
  await rm(tmpDir, { recursive: true, force: true });
50
67
  }
51
68
  }
52
69
 
53
- export async function deleteGist(id, { ghApi }) {
54
- await ghApi(['--method', 'DELETE', `gists/${id}`]);
70
+ export async function deleteGist(id, { ghApi, host = DEFAULT_HOST }) {
71
+ await ghApi([...hostFlags(host), '--method', 'DELETE', `gists/${id}`]);
55
72
  }
56
73
 
57
74
  // --- receive side (anonymous fetch, no gh — ever) ---------------------------
@@ -70,25 +87,50 @@ async function readBodyCapped(res, cap, what) {
70
87
  return Buffer.concat(chunks);
71
88
  }
72
89
 
73
- export async function fetchGistPackage(id, { fetch }) {
74
- let res;
75
- try {
76
- res = await fetch(`https://api.github.com/gists/${id}`, { headers: FETCH_HEADERS });
77
- } catch (e) {
78
- throw new CliError(`Network error reaching GitHub: ${e.message}`, 1);
79
- }
80
- if (res.status === 404) throw new CliError(MSG.gistDeleted, 1);
81
- if (res.status === 403 || res.status === 429) {
82
- throw new CliError('GitHub rate limit hit (anonymous reads are 60/hour per IP). Try again in a bit.', 1);
90
+ export async function fetchGistPackage(id, { fetch, host = DEFAULT_HOST, ghApi = null }) {
91
+ let data;
92
+ if (host !== DEFAULT_HOST) {
93
+ // GitHub Enterprise: private by nature. Everything goes through the
94
+ // receiver's own gh auth; no anonymous request touches the host.
95
+ if (!ghApi) throw new CliError(`Can't reach ${host} without gh.`, 2);
96
+ let stdout;
97
+ try {
98
+ stdout = await ghApi(['--hostname', host, `gists/${id}`]);
99
+ } catch (err) {
100
+ if (err instanceof CliError && /404/.test(err.message)) throw new CliError(MSG.gistDeleted, 1);
101
+ throw err;
102
+ }
103
+ try {
104
+ data = JSON.parse(stdout);
105
+ } catch {
106
+ throw new CliError(`Unexpected response from ${host} while fetching the gist.`, 1);
107
+ }
108
+ } else {
109
+ let res;
110
+ try {
111
+ res = await fetch(`https://api.github.com/gists/${id}`, { headers: FETCH_HEADERS });
112
+ } catch (e) {
113
+ throw new CliError(`Network error reaching GitHub: ${e.message}`, 1);
114
+ }
115
+ if (res.status === 404) throw new CliError(MSG.gistDeleted, 1);
116
+ if (res.status === 403 || res.status === 429) {
117
+ throw new CliError('GitHub rate limit hit (anonymous reads are 60/hour per IP). Try again in a bit.', 1);
118
+ }
119
+ if (!res.ok) throw new CliError(`GitHub API error fetching the gist (HTTP ${res.status}).`, 1);
120
+ data = await res.json();
83
121
  }
84
- if (!res.ok) throw new CliError(`GitHub API error fetching the gist (HTTP ${res.status}).`, 1);
85
- const data = await res.json();
86
122
 
87
123
  const pkgFile = data.files?.['package.tgz.b64'];
88
124
  if (!pkgFile) {
89
125
  throw new CliError('No package at that link (the gist has no package.tgz.b64 — not a SkillShark share).', 1);
90
126
  }
91
127
  let b64;
128
+ if (pkgFile.truncated && host !== DEFAULT_HOST) {
129
+ throw new CliError(
130
+ `This enterprise share is too large to fetch inline (the ${host} API truncates files past ~1 MB). Ask the sender to share it as a repo path on ${host} instead.`,
131
+ 1,
132
+ );
133
+ }
92
134
  if (pkgFile.truncated) {
93
135
  let raw;
94
136
  try {
@@ -1,9 +1,12 @@
1
- // Repo transport (install-only in v0.1): gh:owner/repo[/path][@ref], fetched
2
- // anonymously from api.github.com + codeload. The one integrity anchor is the
3
- // commit SHA; #fp= does not apply here (§4.2).
1
+ // Repo transport (install-only): gh:owner/repo[/path][@ref], fetched
2
+ // anonymously from api.github.com + codeload for public github.com, or
3
+ // entirely through the receiver's gh auth for GitHub Enterprise hosts —
4
+ // no anonymous request ever leaves for an enterprise host. The integrity
5
+ // anchor is the commit SHA; #fp= does not apply here (§4.2).
4
6
  import { CliError } from '../errors.js';
5
7
  import { USER_AGENT } from '../version.js';
6
8
  import { extractTarball } from '../pkg.js';
9
+ import { DEFAULT_HOST } from '../source.js';
7
10
 
8
11
  export const REPO_TARBALL_LIMIT = 50 * 1024 * 1024;
9
12
  const FETCH_HEADERS = {
@@ -60,38 +63,68 @@ export function subtreeMapper(subPath) {
60
63
  };
61
64
  }
62
65
 
63
- // Resolve ref → commit SHA (pinned installs skip the API entirely), download
64
- // the codeload tarball, and extract just the subtree through the §7.2 guards.
65
- export async function fetchRepoTree({ owner, repo, path: subPath, ref }, destDir, { fetch }) {
66
+ async function ghJson(ghApi, host, apiPath, what) {
67
+ let stdout;
68
+ try {
69
+ stdout = await ghApi(['--hostname', host, apiPath]);
70
+ } catch (err) {
71
+ if (err instanceof CliError && /404/.test(err.message)) {
72
+ throw new CliError(`${what} not found on ${host}.`, 1);
73
+ }
74
+ throw err;
75
+ }
76
+ try {
77
+ return JSON.parse(stdout);
78
+ } catch {
79
+ throw new CliError(`Unexpected response from ${host} for ${what}.`, 1);
80
+ }
81
+ }
82
+
83
+ // Resolve ref → commit SHA (pinned public installs skip the API entirely),
84
+ // download the tarball, and extract just the subtree through the §7.2 guards.
85
+ export async function fetchRepoTree({ owner, repo, path: subPath, ref, host = DEFAULT_HOST }, destDir, { fetch, ghApi = null }) {
86
+ const enterprise = host !== DEFAULT_HOST;
87
+ if (enterprise && !ghApi) throw new CliError(`Can't reach ${host} without gh.`, 2);
66
88
  let sha;
67
89
  if (ref && /^[0-9a-f]{40}$/.test(ref)) {
68
90
  sha = ref;
69
91
  } else {
70
92
  let resolvedRef = ref;
71
93
  if (!resolvedRef) {
72
- const repoInfo = await getJson(fetch, `https://api.github.com/repos/${owner}/${repo}`, `Repository ${owner}/${repo}`);
94
+ const repoInfo = enterprise
95
+ ? await ghJson(ghApi, host, `repos/${owner}/${repo}`, `Repository ${owner}/${repo}`)
96
+ : await getJson(fetch, `https://api.github.com/repos/${owner}/${repo}`, `Repository ${owner}/${repo}`);
73
97
  resolvedRef = repoInfo.default_branch;
74
98
  if (!resolvedRef) throw new CliError(`Repository ${owner}/${repo} has no default branch.`, 1);
75
99
  }
76
- const commit = await getJson(
77
- fetch,
78
- `https://api.github.com/repos/${owner}/${repo}/commits/${encodeURIComponent(resolvedRef)}`,
79
- `Ref "${resolvedRef}" in ${owner}/${repo}`,
80
- );
100
+ const commitPath = `repos/${owner}/${repo}/commits/${encodeURIComponent(resolvedRef)}`;
101
+ const commit = enterprise
102
+ ? await ghJson(ghApi, host, commitPath, `Ref "${resolvedRef}" in ${owner}/${repo}`)
103
+ : await getJson(fetch, `https://api.github.com/${commitPath}`, `Ref "${resolvedRef}" in ${owner}/${repo}`);
81
104
  sha = commit.sha;
82
105
  if (!sha) throw new CliError(`Could not resolve "${resolvedRef}" to a commit in ${owner}/${repo}.`, 1);
83
106
  }
84
107
 
85
- let res;
86
- try {
87
- res = await fetch(`https://codeload.github.com/${owner}/${repo}/tar.gz/${sha}`, {
88
- headers: { 'User-Agent': USER_AGENT },
89
- });
90
- } catch (e) {
91
- throw new CliError(`Network error downloading the repo tarball: ${e.message}`, 1);
108
+ let tarball;
109
+ if (enterprise) {
110
+ // gh follows the tarball redirect and emits the bytes on stdout
111
+ tarball = await ghApi(['--hostname', host, `repos/${owner}/${repo}/tarball/${sha}`], { binary: true });
112
+ if (!Buffer.isBuffer(tarball)) tarball = Buffer.from(tarball);
113
+ if (tarball.length > REPO_TARBALL_LIMIT) {
114
+ throw new CliError(`The repo tarball exceeds the ${Math.floor(REPO_TARBALL_LIMIT / (1024 * 1024))} MB limit.`, 1);
115
+ }
116
+ } else {
117
+ let res;
118
+ try {
119
+ res = await fetch(`https://codeload.github.com/${owner}/${repo}/tar.gz/${sha}`, {
120
+ headers: { 'User-Agent': USER_AGENT },
121
+ });
122
+ } catch (e) {
123
+ throw new CliError(`Network error downloading the repo tarball: ${e.message}`, 1);
124
+ }
125
+ if (!res.ok) throw new CliError(`Could not download ${owner}/${repo}@${sha.slice(0, 7)} (HTTP ${res.status}).`, 1);
126
+ tarball = await readBodyCapped(res, REPO_TARBALL_LIMIT, 'The repo tarball');
92
127
  }
93
- if (!res.ok) throw new CliError(`Could not download ${owner}/${repo}@${sha.slice(0, 7)} (HTTP ${res.status}).`, 1);
94
- const tarball = await readBodyCapped(res, REPO_TARBALL_LIMIT, 'The repo tarball');
95
128
 
96
129
  await extractTarball(tarball, destDir, { transformPath: subtreeMapper(subPath) });
97
130
  return { sha };
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '0.2.0';
1
+ export const VERSION = '0.3.0';
2
2
  export const USER_AGENT = `skillshark/${VERSION}`;