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 +21 -2
- package/bin/skillshark.js +9 -0
- package/package.json +1 -1
- package/src/gh.js +32 -9
- package/src/install.js +10 -9
- package/src/share.js +29 -11
- package/src/source.js +33 -9
- package/src/transports/gist.js +60 -18
- package/src/transports/repo.js +54 -21
- package/src/version.js +1 -1
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
|
|
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/
|
|
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
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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(`
|
|
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(` ${
|
|
208
|
+
ui.out(` ${installCommand}`);
|
|
198
209
|
ui.out('');
|
|
199
|
-
|
|
200
|
-
ui.out(`
|
|
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([
|
|
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
|
-
//
|
|
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
|
-
|
|
19
|
-
|
|
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 (
|
|
22
|
-
return { kind: 'gist', id:
|
|
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
|
-
|
|
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
|
}
|
package/src/transports/gist.js
CHANGED
|
@@ -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
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
throw new CliError(`
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 {
|
package/src/transports/repo.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
// Repo transport (install-only
|
|
2
|
-
// anonymously from api.github.com + codeload
|
|
3
|
-
//
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 =
|
|
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
|
|
77
|
-
|
|
78
|
-
`
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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.
|
|
1
|
+
export const VERSION = '0.3.0';
|
|
2
2
|
export const USER_AGENT = `skillshark/${VERSION}`;
|