skillshark 0.1.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 +65 -6
- package/bin/skillshark.js +53 -16
- package/package.json +10 -2
- package/src/agents.js +336 -0
- package/src/discover.js +93 -41
- package/src/gh.js +32 -9
- package/src/install.js +294 -120
- package/src/interactive.js +189 -0
- package/src/share.js +36 -17
- package/src/source.js +33 -9
- package/src/transports/gist.js +60 -18
- package/src/transports/repo.js +54 -21
- package/src/ui.js +25 -0
- package/src/version.js +1 -1
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// The interactive session: `skillshark` with no arguments (TTY only) drops
|
|
2
|
+
// into a guided menu; bare `share`/`install`/`inspect`/`revoke` run their
|
|
3
|
+
// wizard one-shot. Built on @clack/prompts — every flow ends in the same
|
|
4
|
+
// audited pipelines as the flag-driven CLI; the wizard only collects answers.
|
|
5
|
+
import { CliError } from './errors.js';
|
|
6
|
+
import { discoverAll } from './discover.js';
|
|
7
|
+
import { parseSource } from './source.js';
|
|
8
|
+
import { runShare, runRevoke } from './share.js';
|
|
9
|
+
import { runInstall, runInspect } from './install.js';
|
|
10
|
+
import { loadConfig } from './config.js';
|
|
11
|
+
import { displayPath } from './ui.js';
|
|
12
|
+
import { AGENTS } from './agents.js';
|
|
13
|
+
import { VERSION } from './version.js';
|
|
14
|
+
|
|
15
|
+
const FIN = [
|
|
16
|
+
' |\\',
|
|
17
|
+
' | \\',
|
|
18
|
+
' ~~~;~~\\~~~~',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
async function ux() {
|
|
22
|
+
const clack = await import('@clack/prompts');
|
|
23
|
+
const pc = (await import('picocolors')).default;
|
|
24
|
+
return { clack, pc };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function bail(clack, value) {
|
|
28
|
+
return value === null || value === undefined || clack.isCancel(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// --- share wizard --------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
async function wizardShare(deps, { clack, pc }) {
|
|
34
|
+
const found = await deps.ui.spin('Scanning for shareable artifacts', () => discoverAll(deps));
|
|
35
|
+
if (!found.length) {
|
|
36
|
+
clack.log.warn('Nothing shareable here or in your home directory. Create a skill first (e.g. .claude/skills/<name>/SKILL.md).');
|
|
37
|
+
return { status: 'cancelled' };
|
|
38
|
+
}
|
|
39
|
+
const pick = await clack.select({
|
|
40
|
+
message: `Share which artifact? ${pc.dim(`(${found.length} found)`)}`,
|
|
41
|
+
maxItems: 12,
|
|
42
|
+
options: found.map((a) => ({
|
|
43
|
+
value: a.root,
|
|
44
|
+
label: a.name,
|
|
45
|
+
hint: `${AGENTS[a.agent]?.label ?? a.agent} ${a.type} · ${a.scope === 'global' ? '~' : displayPath(a.root, deps)}`,
|
|
46
|
+
})),
|
|
47
|
+
});
|
|
48
|
+
if (bail(clack, pick)) return { status: 'cancelled' };
|
|
49
|
+
|
|
50
|
+
const expires = await clack.select({
|
|
51
|
+
message: 'Advisory expiry (installers refuse after this; the gist lives until you revoke):',
|
|
52
|
+
initialValue: '7d',
|
|
53
|
+
options: [
|
|
54
|
+
{ value: '30m', label: '30 minutes' },
|
|
55
|
+
{ value: '6h', label: '6 hours' },
|
|
56
|
+
{ value: '24h', label: '24 hours' },
|
|
57
|
+
{ value: '7d', label: '7 days', hint: 'default' },
|
|
58
|
+
{ value: '30d', label: '30 days' },
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
if (bail(clack, expires)) return { status: 'cancelled' };
|
|
62
|
+
|
|
63
|
+
return runShare(pick, { expires }, deps);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- install / inspect wizards ----------------------------------------------------
|
|
67
|
+
|
|
68
|
+
async function askSource(deps, { clack }) {
|
|
69
|
+
for (;;) {
|
|
70
|
+
const source = await clack.text({
|
|
71
|
+
message: 'Paste the link (gist URL, bare id, or gh:owner/repo[/path][@ref]):',
|
|
72
|
+
placeholder: 'https://gist.github.com/…#fp=…',
|
|
73
|
+
});
|
|
74
|
+
if (bail(clack, source)) return null;
|
|
75
|
+
try {
|
|
76
|
+
parseSource(String(source).trim());
|
|
77
|
+
return String(source).trim();
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (err instanceof CliError) {
|
|
80
|
+
clack.log.error(err.message);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function wizardInstall(deps, u) {
|
|
89
|
+
const source = await askSource(deps, u);
|
|
90
|
+
if (source === null) return { status: 'cancelled' };
|
|
91
|
+
return runInstall(source, {}, deps);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function wizardInspect(deps, u) {
|
|
95
|
+
const { clack } = u;
|
|
96
|
+
const source = await askSource(deps, u);
|
|
97
|
+
if (source === null) return { status: 'cancelled' };
|
|
98
|
+
await runInspect(source, {}, deps);
|
|
99
|
+
const next = await clack.select({
|
|
100
|
+
message: 'And now?',
|
|
101
|
+
options: [
|
|
102
|
+
{ value: 'install', label: 'Install it' },
|
|
103
|
+
{ value: 'done', label: 'Done' },
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
if (!bail(clack, next) && next === 'install') {
|
|
107
|
+
return runInstall(source, {}, deps);
|
|
108
|
+
}
|
|
109
|
+
return { status: 'inspected' };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- revoke wizard -----------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
async function wizardRevoke(deps, { clack, pc }) {
|
|
115
|
+
const cfg = await loadConfig(deps.configDir);
|
|
116
|
+
let idOrName = null;
|
|
117
|
+
if (cfg.shares.length) {
|
|
118
|
+
const pick = await clack.select({
|
|
119
|
+
message: 'Revoke which share?',
|
|
120
|
+
maxItems: 12,
|
|
121
|
+
options: [
|
|
122
|
+
...cfg.shares.map((s) => ({
|
|
123
|
+
value: s.id,
|
|
124
|
+
label: s.name,
|
|
125
|
+
hint: `${s.id.slice(0, 12)}… · expires ${s.expiresAt ? s.expiresAt.slice(0, 10) : 'never'}`,
|
|
126
|
+
})),
|
|
127
|
+
{ value: '__manual', label: 'Enter a gist id or name…' },
|
|
128
|
+
],
|
|
129
|
+
});
|
|
130
|
+
if (bail(clack, pick)) return { status: 'cancelled' };
|
|
131
|
+
idOrName = pick === '__manual' ? null : pick;
|
|
132
|
+
}
|
|
133
|
+
if (idOrName === null) {
|
|
134
|
+
const entered = await clack.text({ message: 'Gist id (or share name):' });
|
|
135
|
+
if (bail(clack, entered) || !String(entered).trim()) return { status: 'cancelled' };
|
|
136
|
+
idOrName = String(entered).trim();
|
|
137
|
+
}
|
|
138
|
+
clack.log.warn(pc.yellow('Revoking deletes the gist — anyone holding the link loses access immediately.'));
|
|
139
|
+
return runRevoke(idOrName, {}, deps);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- the session --------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
const WIZARDS = {
|
|
145
|
+
share: wizardShare,
|
|
146
|
+
install: wizardInstall,
|
|
147
|
+
inspect: wizardInspect,
|
|
148
|
+
revoke: wizardRevoke,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// action: run a single wizard (bare `skillshark share` etc.); null: full menu.
|
|
152
|
+
export async function runInteractive(deps, action = null) {
|
|
153
|
+
const u = await ux();
|
|
154
|
+
const { clack, pc } = u;
|
|
155
|
+
|
|
156
|
+
clack.intro(`${pc.cyan(FIN.join('\n '))}\n ${pc.bold('skillshark')} ${pc.dim(`v${VERSION} — share agent skills like files`)}`);
|
|
157
|
+
|
|
158
|
+
if (action) {
|
|
159
|
+
const result = await WIZARDS[action](deps, u);
|
|
160
|
+
clack.outro(result?.status === 'cancelled' ? 'Nothing happened. 🦈' : 'Done. 🦈');
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (;;) {
|
|
165
|
+
const choice = await clack.select({
|
|
166
|
+
message: 'What are we doing?',
|
|
167
|
+
options: [
|
|
168
|
+
{ value: 'share', label: 'Share', hint: 'package a local skill → unlisted link' },
|
|
169
|
+
{ value: 'install', label: 'Install', hint: 'from a link or repo path' },
|
|
170
|
+
{ value: 'inspect', label: 'Inspect', hint: 'look before you leap' },
|
|
171
|
+
{ value: 'revoke', label: 'Revoke', hint: 'kill a link you shared' },
|
|
172
|
+
{ value: 'quit', label: 'Quit' },
|
|
173
|
+
],
|
|
174
|
+
});
|
|
175
|
+
if (bail(clack, choice) || choice === 'quit') break;
|
|
176
|
+
try {
|
|
177
|
+
await WIZARDS[choice](deps, u);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
if (err instanceof CliError) {
|
|
180
|
+
clack.log.error(err.message);
|
|
181
|
+
} else {
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
clack.log.message('');
|
|
186
|
+
}
|
|
187
|
+
clack.outro('Swim safe. 🦈');
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
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,14 +154,22 @@ export async function runShare(arg, opts, deps) {
|
|
|
153
154
|
} catch { /* preview is optional */ }
|
|
154
155
|
}
|
|
155
156
|
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
157
|
+
const host = resolveHost(opts, deps);
|
|
158
|
+
const { id, revision, htmlUrl } = await ui.spin('Uploading as a secret gist', () =>
|
|
159
|
+
createGist({
|
|
160
|
+
manifestJson,
|
|
161
|
+
primaryDoc,
|
|
162
|
+
tarballB64: b64,
|
|
163
|
+
description: gistDescription({ name: manifest.name, agent: manifest.agent, type: manifest.type, fp8: shortFp }),
|
|
164
|
+
ghApi: deps.ghApi,
|
|
165
|
+
host,
|
|
166
|
+
}));
|
|
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}'`;
|
|
164
173
|
|
|
165
174
|
await addShareRecord(deps.configDir, {
|
|
166
175
|
id,
|
|
@@ -168,37 +177,41 @@ export async function runShare(arg, opts, deps) {
|
|
|
168
177
|
url,
|
|
169
178
|
revision,
|
|
170
179
|
expiresAt: manifest.expiresAt,
|
|
180
|
+
...(host !== DEFAULT_HOST ? { host } : {}),
|
|
171
181
|
});
|
|
172
182
|
|
|
173
183
|
let copied = false;
|
|
174
184
|
if (!opts.noClipboard && deps.clipboard) {
|
|
175
|
-
copied = await deps.clipboard(
|
|
185
|
+
copied = await deps.clipboard(installCommand);
|
|
176
186
|
}
|
|
177
187
|
|
|
178
188
|
if (opts.json) {
|
|
179
189
|
ui.out(JSON.stringify({
|
|
180
190
|
id,
|
|
181
191
|
url,
|
|
192
|
+
installCommand,
|
|
182
193
|
revision,
|
|
183
194
|
expiresAt: manifest.expiresAt,
|
|
184
195
|
fingerprint,
|
|
185
196
|
size: manifest.totalSize,
|
|
186
197
|
files: manifest.files.map((f) => f.path),
|
|
198
|
+
...(host !== DEFAULT_HOST ? { host } : {}),
|
|
187
199
|
}));
|
|
188
200
|
} else if (opts.quiet) {
|
|
189
201
|
ui.out(url);
|
|
190
202
|
} else {
|
|
191
203
|
ui.out('');
|
|
192
204
|
ui.ok('Uploaded as a secret gist (unlisted — anyone with the link can read it)');
|
|
193
|
-
if (copied) ui.ok(`
|
|
205
|
+
if (copied) ui.ok(`Install one-liner copied to clipboard — they just paste it · advisory expiry in ${expires.label}`);
|
|
194
206
|
else ui.out(` Advisory expiry in ${expires.label}`);
|
|
195
207
|
ui.out('');
|
|
196
|
-
ui.out(` ${
|
|
208
|
+
ui.out(` ${installCommand}`);
|
|
197
209
|
ui.out('');
|
|
198
|
-
|
|
199
|
-
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)`);
|
|
200
213
|
}
|
|
201
|
-
return { status: 'shared', id, url, fingerprint };
|
|
214
|
+
return { status: 'shared', id, url, installCommand, fingerprint };
|
|
202
215
|
}
|
|
203
216
|
|
|
204
217
|
// --- revoke (§4.4) -----------------------------------------------------------
|
|
@@ -207,6 +220,7 @@ export async function runRevoke(idOrName, opts, deps) {
|
|
|
207
220
|
const ui = deps.ui;
|
|
208
221
|
let id = null;
|
|
209
222
|
let label = idOrName;
|
|
223
|
+
let host = resolveHost(opts, deps);
|
|
210
224
|
if (/^[0-9a-f]{20,32}$/.test(idOrName)) {
|
|
211
225
|
id = idOrName;
|
|
212
226
|
} else {
|
|
@@ -214,9 +228,14 @@ export async function runRevoke(idOrName, opts, deps) {
|
|
|
214
228
|
if (rec) {
|
|
215
229
|
id = rec.id;
|
|
216
230
|
label = `${rec.name} (${rec.id})`;
|
|
231
|
+
if (rec.host) host = rec.host; // enterprise share → revoke on its host
|
|
217
232
|
} else {
|
|
218
|
-
// cache miss → ask gh for our skillshark gists
|
|
219
|
-
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
|
+
]);
|
|
220
239
|
let gists;
|
|
221
240
|
try {
|
|
222
241
|
gists = JSON.parse(out);
|
|
@@ -241,7 +260,7 @@ export async function runRevoke(idOrName, opts, deps) {
|
|
|
241
260
|
return { status: 'cancelled' };
|
|
242
261
|
}
|
|
243
262
|
}
|
|
244
|
-
await deleteGist(id, { ghApi: deps.ghApi });
|
|
263
|
+
await deleteGist(id, { ghApi: deps.ghApi, host });
|
|
245
264
|
await removeShareRecord(deps.configDir, id);
|
|
246
265
|
if (opts.json) ui.out(JSON.stringify({ revoked: id }));
|
|
247
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/ui.js
CHANGED
|
@@ -37,9 +37,29 @@ export function makeUi({ stdout = process.stdout, stderr = process.stderr, color
|
|
|
37
37
|
warn: (s) => write(stdout, ` ${c.yellow('⚠')} ${s}`),
|
|
38
38
|
info: (s) => write(stdout, ` ${c.cyan('ⓘ')} ${s}`),
|
|
39
39
|
fail: (s) => write(stderr, ` ${c.red('✗')} ${s}`),
|
|
40
|
+
// network-op wrapper; the TTY entrypoint swaps in a real spinner
|
|
41
|
+
spin: async (_label, fn) => fn(),
|
|
40
42
|
};
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
// Replace ui.spin with an animated @clack spinner (TTY only).
|
|
46
|
+
export async function attachSpinner(ui) {
|
|
47
|
+
const clack = await import('@clack/prompts');
|
|
48
|
+
ui.spin = async (label, fn) => {
|
|
49
|
+
const s = clack.spinner();
|
|
50
|
+
s.start(label);
|
|
51
|
+
try {
|
|
52
|
+
const result = await fn();
|
|
53
|
+
s.stop(`${label} — done`);
|
|
54
|
+
return result;
|
|
55
|
+
} catch (err) {
|
|
56
|
+
s.stop(`${label} — failed`, 1);
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
return ui;
|
|
61
|
+
}
|
|
62
|
+
|
|
43
63
|
// The install/inspect preview, rendered only from verified bytes (§4.2 step 5).
|
|
44
64
|
export function renderPreview(ui, { manifest, fingerprint, fpFromLink, externalRefs }) {
|
|
45
65
|
const c = ui.colors;
|
|
@@ -99,5 +119,10 @@ export async function realPrompts() {
|
|
|
99
119
|
if (clack.isCancel(value)) return null;
|
|
100
120
|
return value;
|
|
101
121
|
},
|
|
122
|
+
async text({ message, placeholder }) {
|
|
123
|
+
const value = await clack.text({ message, placeholder });
|
|
124
|
+
if (clack.isCancel(value)) return null;
|
|
125
|
+
return String(value ?? '').trim();
|
|
126
|
+
},
|
|
102
127
|
};
|
|
103
128
|
}
|
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}`;
|