skillshark 0.1.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 +87 -0
- package/bin/skillshark.js +256 -0
- package/package.json +36 -0
- package/src/clipboard.js +45 -0
- package/src/config.js +62 -0
- package/src/discover.js +298 -0
- package/src/errors.js +21 -0
- package/src/fingerprint.js +29 -0
- package/src/gh.js +34 -0
- package/src/install.js +502 -0
- package/src/pkg.js +260 -0
- package/src/share.js +249 -0
- package/src/source.js +58 -0
- package/src/transports/gist.js +117 -0
- package/src/transports/repo.js +98 -0
- package/src/ui.js +103 -0
- package/src/version.js +2 -0
package/src/pkg.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// The security core: build, extract, verify. SkillShark never executes
|
|
2
|
+
// package content — extraction writes regular files and directories, nothing else.
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, readFile, readdir, chmod, rm, stat } from 'node:fs/promises';
|
|
4
|
+
import { createGunzip } from 'node:zlib';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import * as tar from 'tar';
|
|
8
|
+
import { sha256hex, treeFingerprint } from './fingerprint.js';
|
|
9
|
+
import { CliError, MSG } from './errors.js';
|
|
10
|
+
|
|
11
|
+
export const MAX_DECOMPRESSED_BYTES = 50 * 1024 * 1024;
|
|
12
|
+
export const MAX_FILES = 500;
|
|
13
|
+
export const MANIFEST_NAME = 'skillshark.json';
|
|
14
|
+
|
|
15
|
+
export class ExtractError extends CliError {
|
|
16
|
+
constructor(message, details) {
|
|
17
|
+
super(message, 1, details);
|
|
18
|
+
this.name = 'ExtractError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// --- build ----------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
// files: [{ path, abs, executable }] — path is "/"-separated, relative to package root.
|
|
25
|
+
// Returns the canonical tarball with `skillshark.json` at the archive root.
|
|
26
|
+
export async function buildTarball(files, manifest) {
|
|
27
|
+
const staging = await mkdtemp(path.join(os.tmpdir(), 'skillshark-pack-'));
|
|
28
|
+
try {
|
|
29
|
+
for (const f of files) {
|
|
30
|
+
const dest = path.join(staging, ...f.path.split('/'));
|
|
31
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
32
|
+
await writeFile(dest, await readFile(f.abs));
|
|
33
|
+
await chmod(dest, f.executable ? 0o755 : 0o644);
|
|
34
|
+
}
|
|
35
|
+
const manifestJson = JSON.stringify(manifest, null, 2) + '\n';
|
|
36
|
+
await writeFile(path.join(staging, MANIFEST_NAME), manifestJson);
|
|
37
|
+
const tarFile = path.join(staging, '..', `skillshark-tar-${process.pid}-${Date.now()}.tgz`);
|
|
38
|
+
try {
|
|
39
|
+
await tar.create(
|
|
40
|
+
{ file: tarFile, gzip: true, cwd: staging, portable: true },
|
|
41
|
+
[MANIFEST_NAME, ...files.map((f) => f.path)],
|
|
42
|
+
);
|
|
43
|
+
return { tarball: await readFile(tarFile), manifestJson };
|
|
44
|
+
} finally {
|
|
45
|
+
await rm(tarFile, { force: true });
|
|
46
|
+
}
|
|
47
|
+
} finally {
|
|
48
|
+
await rm(staging, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- extract ---------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
// Reject anything that could land outside the extraction root. Returns the
|
|
55
|
+
// cleaned, "/"-joined relative path ('' for the root itself).
|
|
56
|
+
function cleanEntryPath(raw) {
|
|
57
|
+
let p = String(raw);
|
|
58
|
+
if (p.includes('\u0000')) throw new ExtractError('Refused tar entry: NUL byte in path.');
|
|
59
|
+
if (p.includes('\\')) throw new ExtractError(`Refused tar entry "${p}": backslash in path.`);
|
|
60
|
+
if (/^[A-Za-z]:/.test(p)) throw new ExtractError(`Refused tar entry "${p}": drive letter path.`);
|
|
61
|
+
if (p.startsWith('/')) throw new ExtractError(`Refused tar entry "${p}": absolute path.`);
|
|
62
|
+
const segs = p.split('/').filter((s) => s !== '' && s !== '.');
|
|
63
|
+
if (segs.includes('..')) throw new ExtractError(`Refused tar entry "${p}": path traversal ("..").`);
|
|
64
|
+
return segs.join('/');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Guarded streaming extraction (§7.2). Own entry filter — node-tar's built-in
|
|
68
|
+
// protections are not relied on. transformPath(cleanPath) → string|null lets the
|
|
69
|
+
// repo transport strip the codeload prefix and select a subtree; null skips.
|
|
70
|
+
export async function extractTarball(tarball, destRoot, opts = {}) {
|
|
71
|
+
const {
|
|
72
|
+
maxBytes = MAX_DECOMPRESSED_BYTES,
|
|
73
|
+
maxFiles = MAX_FILES,
|
|
74
|
+
transformPath = null,
|
|
75
|
+
} = opts;
|
|
76
|
+
|
|
77
|
+
const root = path.resolve(destRoot);
|
|
78
|
+
const entries = [];
|
|
79
|
+
let bytesSeen = 0;
|
|
80
|
+
let fileCount = 0;
|
|
81
|
+
let openEntries = 0;
|
|
82
|
+
|
|
83
|
+
await new Promise((resolve, reject) => {
|
|
84
|
+
let settled = false;
|
|
85
|
+
const gunzip = createGunzip();
|
|
86
|
+
const parser = new tar.Parser();
|
|
87
|
+
const fail = (err) => {
|
|
88
|
+
if (settled) return;
|
|
89
|
+
settled = true;
|
|
90
|
+
try { gunzip.destroy(); } catch { /* already dead */ }
|
|
91
|
+
try { parser.abort(err); } catch { /* best effort */ }
|
|
92
|
+
reject(err);
|
|
93
|
+
};
|
|
94
|
+
const done = () => {
|
|
95
|
+
if (settled) return;
|
|
96
|
+
if (openEntries > 0) {
|
|
97
|
+
fail(new ExtractError('Truncated archive: a file entry ended early. Nothing was installed.'));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
settled = true;
|
|
101
|
+
resolve();
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
parser.on('entry', (entry) => {
|
|
105
|
+
try {
|
|
106
|
+
if (entry.meta) { entry.resume(); return; } // pax/gnu metadata consumed by the parser
|
|
107
|
+
const cleaned = cleanEntryPath(entry.path);
|
|
108
|
+
const mapped = transformPath ? transformPath(cleaned, entry.type) : cleaned;
|
|
109
|
+
if (mapped === null || mapped === undefined || mapped === '') {
|
|
110
|
+
if (entry.type === 'File' && !transformPath && mapped === '') {
|
|
111
|
+
throw new ExtractError(`Refused tar entry "${entry.path}": empty path.`);
|
|
112
|
+
}
|
|
113
|
+
entry.resume();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const rel = cleanEntryPath(mapped); // re-validate after any transform
|
|
117
|
+
const resolved = path.resolve(root, ...rel.split('/'));
|
|
118
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
|
119
|
+
throw new ExtractError(`Refused tar entry "${entry.path}": escapes extraction root.`);
|
|
120
|
+
}
|
|
121
|
+
if (entry.type === 'Directory') {
|
|
122
|
+
entries.push({ rel, type: 'dir' });
|
|
123
|
+
entry.resume();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (entry.type !== 'File') {
|
|
127
|
+
throw new ExtractError(
|
|
128
|
+
`Refused tar entry "${entry.path}": type "${entry.type}" is not allowed (only files and directories).`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
fileCount += 1;
|
|
132
|
+
if (fileCount > maxFiles) {
|
|
133
|
+
throw new ExtractError(`Package has too many files (limit ${maxFiles}). Nothing was installed.`);
|
|
134
|
+
}
|
|
135
|
+
openEntries += 1;
|
|
136
|
+
const expected = entry.size ?? 0;
|
|
137
|
+
const chunks = [];
|
|
138
|
+
entry.on('data', (c) => chunks.push(c));
|
|
139
|
+
entry.on('end', () => {
|
|
140
|
+
const data = Buffer.concat(chunks);
|
|
141
|
+
if (data.length !== expected) {
|
|
142
|
+
fail(new ExtractError(`Truncated archive: "${rel}" ended early. Nothing was installed.`));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
openEntries -= 1;
|
|
146
|
+
entries.push({ rel, type: 'file', mode: entry.mode ?? 0o644, data });
|
|
147
|
+
});
|
|
148
|
+
} catch (err) {
|
|
149
|
+
fail(err);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
parser.on('error', fail);
|
|
153
|
+
// strict mode: a malformed entry the parser would skip (bad checksum,
|
|
154
|
+
// forbidden linkpath, unsupported extension) aborts the whole extraction
|
|
155
|
+
parser.on('warn', (code, message) => {
|
|
156
|
+
fail(new ExtractError(`Refused malformed archive entry (${code}: ${message}). Nothing was installed.`));
|
|
157
|
+
});
|
|
158
|
+
parser.on('end', done);
|
|
159
|
+
parser.on('close', done);
|
|
160
|
+
|
|
161
|
+
gunzip.on('data', (chunk) => {
|
|
162
|
+
bytesSeen += chunk.length;
|
|
163
|
+
if (bytesSeen > maxBytes) {
|
|
164
|
+
fail(new ExtractError(
|
|
165
|
+
`Package expands past the ${Math.floor(maxBytes / (1024 * 1024))} MB safety limit. Nothing was installed.`,
|
|
166
|
+
{ bytesSeen },
|
|
167
|
+
));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
parser.write(chunk);
|
|
171
|
+
});
|
|
172
|
+
gunzip.on('end', () => parser.end());
|
|
173
|
+
gunzip.on('error', (e) => fail(new ExtractError(`Not a valid package (gzip): ${e.message}`)));
|
|
174
|
+
gunzip.end(tarball);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
for (const e of entries) {
|
|
178
|
+
const dest = path.resolve(root, ...e.rel.split('/'));
|
|
179
|
+
if (e.type === 'dir') {
|
|
180
|
+
await mkdir(dest, { recursive: true });
|
|
181
|
+
} else {
|
|
182
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
183
|
+
await writeFile(dest, e.data, { mode: (e.mode & 0o111) ? 0o755 : 0o644 });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { bytesSeen, fileCount };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// --- verify ----------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
// Walk an on-disk tree → [{ path, size, sha256, executable }], sorted by path.
|
|
192
|
+
// Symlinks and special files are never followed or hashed.
|
|
193
|
+
export async function hashTree(dir, { exclude = [MANIFEST_NAME] } = {}) {
|
|
194
|
+
const out = [];
|
|
195
|
+
async function walk(d, prefix) {
|
|
196
|
+
const dirents = await readdir(d, { withFileTypes: true });
|
|
197
|
+
for (const ent of dirents) {
|
|
198
|
+
const rel = prefix ? `${prefix}/${ent.name}` : ent.name;
|
|
199
|
+
if (!prefix && exclude.includes(ent.name)) continue;
|
|
200
|
+
if (ent.isDirectory()) await walk(path.join(d, ent.name), rel);
|
|
201
|
+
else if (ent.isFile()) {
|
|
202
|
+
const abs = path.join(d, ent.name);
|
|
203
|
+
const data = await readFile(abs);
|
|
204
|
+
const { mode } = await stat(abs);
|
|
205
|
+
out.push({
|
|
206
|
+
path: rel,
|
|
207
|
+
size: data.length,
|
|
208
|
+
sha256: sha256hex(data),
|
|
209
|
+
executable: Boolean(mode & 0o111),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
// anything else (symlink smuggled in, fifo, …) is ignored: we never wrote it
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
await walk(dir, '');
|
|
216
|
+
out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function readManifest(dir) {
|
|
221
|
+
let raw;
|
|
222
|
+
try {
|
|
223
|
+
raw = await readFile(path.join(dir, MANIFEST_NAME), 'utf8');
|
|
224
|
+
} catch {
|
|
225
|
+
throw new CliError('No package at that link (missing skillshark.json — not a SkillShark share).', 1);
|
|
226
|
+
}
|
|
227
|
+
let manifest;
|
|
228
|
+
try {
|
|
229
|
+
manifest = JSON.parse(raw);
|
|
230
|
+
} catch {
|
|
231
|
+
throw new CliError(MSG.downloadIntegrity, 1);
|
|
232
|
+
}
|
|
233
|
+
if (!manifest || !Array.isArray(manifest.files) || typeof manifest.name !== 'string') {
|
|
234
|
+
throw new CliError(MSG.downloadIntegrity, 1);
|
|
235
|
+
}
|
|
236
|
+
return manifest;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Per-file sha256 + exact set equality between the manifest and the extracted
|
|
240
|
+
// tree. Returns the tree fingerprint computed from the *actual* bytes.
|
|
241
|
+
export function verifyTreeAgainstManifest(actualFiles, manifest) {
|
|
242
|
+
const actualByPath = new Map(actualFiles.map((f) => [f.path, f]));
|
|
243
|
+
const manifestByPath = new Map();
|
|
244
|
+
for (const f of manifest.files) {
|
|
245
|
+
if (typeof f.path !== 'string' || typeof f.sha256 !== 'string') {
|
|
246
|
+
throw new CliError(MSG.downloadIntegrity, 1);
|
|
247
|
+
}
|
|
248
|
+
manifestByPath.set(f.path, f);
|
|
249
|
+
}
|
|
250
|
+
if (actualByPath.size !== manifestByPath.size) throw new CliError(MSG.downloadIntegrity, 1);
|
|
251
|
+
for (const [p, mf] of manifestByPath) {
|
|
252
|
+
const af = actualByPath.get(p);
|
|
253
|
+
if (!af || af.sha256 !== mf.sha256) throw new CliError(MSG.downloadIntegrity, 1);
|
|
254
|
+
}
|
|
255
|
+
const fingerprint = treeFingerprint(actualFiles);
|
|
256
|
+
if (manifest.fingerprint && manifest.fingerprint !== fingerprint) {
|
|
257
|
+
throw new CliError(MSG.downloadIntegrity, 1);
|
|
258
|
+
}
|
|
259
|
+
return fingerprint;
|
|
260
|
+
}
|
package/src/share.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// `skillshark share <path|name>` (§4.1) and `skillshark revoke <id|name>` (§4.4).
|
|
2
|
+
// Sender operations are the only ones that use gh; they never run package content.
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { CliError } from './errors.js';
|
|
6
|
+
import { resolveShareArg, collectFiles, inferMetadata, findExternalRefs } from './discover.js';
|
|
7
|
+
import { buildTarball } from './pkg.js';
|
|
8
|
+
import { treeFingerprint, fp8 } from './fingerprint.js';
|
|
9
|
+
import { createGist, deleteGist, gistDescription, GIST_PAYLOAD_LIMIT } from './transports/gist.js';
|
|
10
|
+
import { addShareRecord, findShareRecord, removeShareRecord } from './config.js';
|
|
11
|
+
import { humanSize, displayPath, plural } from './ui.js';
|
|
12
|
+
import { VERSION } from './version.js';
|
|
13
|
+
|
|
14
|
+
const EXPIRES = {
|
|
15
|
+
'30m': 30 * 60 * 1000,
|
|
16
|
+
'6h': 6 * 3600 * 1000,
|
|
17
|
+
'24h': 24 * 3600 * 1000,
|
|
18
|
+
'7d': 7 * 86400000,
|
|
19
|
+
'30d': 30 * 86400000,
|
|
20
|
+
};
|
|
21
|
+
const EXPIRES_LABEL = {
|
|
22
|
+
'30m': '30 minutes',
|
|
23
|
+
'6h': '6 hours',
|
|
24
|
+
'24h': '24 hours',
|
|
25
|
+
'7d': '7 days',
|
|
26
|
+
'30d': '30 days',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function parseExpires(value) {
|
|
30
|
+
const v = value ?? '7d';
|
|
31
|
+
if (!Object.hasOwn(EXPIRES, v)) {
|
|
32
|
+
throw new CliError(`--expires must be one of: 30m, 6h, 24h, 7d, 30d (got "${value}").`, 2);
|
|
33
|
+
}
|
|
34
|
+
return { ms: EXPIRES[v], label: EXPIRES_LABEL[v], key: v };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Assemble everything `share` needs before any upload happens. Also used by
|
|
38
|
+
// --dry-run. Returns { files, warnings, manifest, manifestJson, tarball, fp }.
|
|
39
|
+
export async function buildSharePackage(arg, opts, deps) {
|
|
40
|
+
const { matches } = await resolveShareArg(arg, deps);
|
|
41
|
+
let match;
|
|
42
|
+
if (matches.length === 1) {
|
|
43
|
+
[match] = matches;
|
|
44
|
+
} else if (deps.isTTY && deps.prompts) {
|
|
45
|
+
const value = await deps.prompts.select({
|
|
46
|
+
message: `"${arg}" matches more than one artifact:`,
|
|
47
|
+
options: matches.map((m, i) => ({ value: i, label: `${m.type} at ${displayPath(m.root, deps)}`, hint: m.where })),
|
|
48
|
+
});
|
|
49
|
+
if (value === null) throw new CliError('Cancelled.', 0);
|
|
50
|
+
match = matches[value];
|
|
51
|
+
} else {
|
|
52
|
+
throw new CliError(
|
|
53
|
+
`"${arg}" is ambiguous here:\n${matches.map((m) => ` ${m.type} ${displayPath(m.root, deps)}`).join('\n')}\nPass the path you mean.`,
|
|
54
|
+
2,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const { files, warnings } = await collectFiles(match.root, { isDir: match.isDir, force: opts.force });
|
|
59
|
+
if (files.length === 0) {
|
|
60
|
+
throw new CliError(
|
|
61
|
+
'Nothing to package: every file was excluded (or the directory is empty). Use --force to include secret-shaped files you really mean to share.',
|
|
62
|
+
2,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const meta = await inferMetadata({
|
|
66
|
+
root: match.root,
|
|
67
|
+
isDir: match.isDir,
|
|
68
|
+
type: match.type,
|
|
69
|
+
agent: match.agent,
|
|
70
|
+
files,
|
|
71
|
+
});
|
|
72
|
+
const name = opts.name || meta.name;
|
|
73
|
+
const expires = parseExpires(opts.expires);
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
const fingerprint = treeFingerprint(files);
|
|
76
|
+
const manifest = {
|
|
77
|
+
skillshark: '2',
|
|
78
|
+
name,
|
|
79
|
+
type: match.type,
|
|
80
|
+
agent: match.agent,
|
|
81
|
+
description: meta.description,
|
|
82
|
+
files: files.map(({ path: p, size, sha256, mode, executable }) => ({ path: p, size, sha256, mode, executable })),
|
|
83
|
+
totalSize: files.reduce((n, f) => n + f.size, 0),
|
|
84
|
+
createdAt: new Date(now).toISOString(),
|
|
85
|
+
expiresAt: new Date(now + expires.ms).toISOString(),
|
|
86
|
+
tool: { name: 'skillshark', version: VERSION },
|
|
87
|
+
dependencies: meta.dependencies,
|
|
88
|
+
fingerprint,
|
|
89
|
+
};
|
|
90
|
+
const externalRefs = await findExternalRefs(files);
|
|
91
|
+
const { tarball, manifestJson } = await buildTarball(
|
|
92
|
+
files.map((f) => ({ path: f.path, abs: f.abs, executable: f.executable })),
|
|
93
|
+
manifest,
|
|
94
|
+
);
|
|
95
|
+
return { match, files, warnings, manifest, manifestJson, tarball, fingerprint, expires, externalRefs, meta };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function runShare(arg, opts, deps) {
|
|
99
|
+
const ui = deps.ui;
|
|
100
|
+
const built = await buildSharePackage(arg, opts, deps);
|
|
101
|
+
const { match, files, warnings, manifest, manifestJson, tarball, fingerprint, expires, externalRefs } = built;
|
|
102
|
+
const shortFp = fp8(fingerprint);
|
|
103
|
+
const loud = !opts.quiet && !opts.json;
|
|
104
|
+
|
|
105
|
+
if (loud) {
|
|
106
|
+
const kind = manifest.agent ? `${manifest.type} "${manifest.name}" (${manifest.agent})` : `${manifest.type} "${manifest.name}"`;
|
|
107
|
+
ui.out(` Found ${kind} at ${displayPath(match.root, deps)} — ${plural(files.length, 'file')}, ${humanSize(manifest.totalSize)}`);
|
|
108
|
+
for (const w of warnings) {
|
|
109
|
+
if (w.forceable) ui.warn(`Skipped ${w.path} (${w.reason}) — pass --force to include`);
|
|
110
|
+
else ui.warn(`Skipped ${w.path} (${w.reason})`);
|
|
111
|
+
}
|
|
112
|
+
for (const ref of externalRefs) {
|
|
113
|
+
ui.warn(`References ${ref} outside the package — it may not work standalone`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const b64 = tarball.toString('base64');
|
|
118
|
+
if (b64.length > GIST_PAYLOAD_LIMIT) {
|
|
119
|
+
throw new CliError(
|
|
120
|
+
`That's ${humanSize(b64.length)} (gist limit ~5 MB). Put it in a repo and share gh:owner/repo/path instead.`,
|
|
121
|
+
2,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (opts.dryRun) {
|
|
126
|
+
if (opts.json) {
|
|
127
|
+
ui.out(JSON.stringify({
|
|
128
|
+
dryRun: true,
|
|
129
|
+
name: manifest.name,
|
|
130
|
+
type: manifest.type,
|
|
131
|
+
agent: manifest.agent,
|
|
132
|
+
fingerprint,
|
|
133
|
+
size: manifest.totalSize,
|
|
134
|
+
encodedSize: b64.length,
|
|
135
|
+
files: manifest.files,
|
|
136
|
+
}, null, 2));
|
|
137
|
+
} else {
|
|
138
|
+
ui.out('');
|
|
139
|
+
for (const f of manifest.files) {
|
|
140
|
+
ui.out(` ${f.path.padEnd(Math.max(...manifest.files.map((x) => x.path.length)) + 3)}${humanSize(f.size)}${f.executable ? ' (executable)' : ''}`);
|
|
141
|
+
}
|
|
142
|
+
ui.out('');
|
|
143
|
+
ui.out(` Fingerprint ${shortFp} · ${humanSize(b64.length)} encoded · nothing uploaded (--dry-run)`);
|
|
144
|
+
}
|
|
145
|
+
return { status: 'dry-run', fingerprint };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let primaryDoc = null;
|
|
149
|
+
if (built.meta.primaryDoc) {
|
|
150
|
+
const abs = match.isDir ? path.join(match.root, ...built.meta.primaryDoc.split('/')) : match.root;
|
|
151
|
+
try {
|
|
152
|
+
primaryDoc = { name: built.meta.primaryDoc, content: await readFile(abs, 'utf8') };
|
|
153
|
+
} catch { /* preview is optional */ }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const { id, revision } = await createGist({
|
|
157
|
+
manifestJson,
|
|
158
|
+
primaryDoc,
|
|
159
|
+
tarballB64: b64,
|
|
160
|
+
description: gistDescription({ name: manifest.name, agent: manifest.agent, type: manifest.type, fp8: shortFp }),
|
|
161
|
+
ghApi: deps.ghApi,
|
|
162
|
+
});
|
|
163
|
+
const url = `https://gist.github.com/${id}#fp=${shortFp}`;
|
|
164
|
+
|
|
165
|
+
await addShareRecord(deps.configDir, {
|
|
166
|
+
id,
|
|
167
|
+
name: manifest.name,
|
|
168
|
+
url,
|
|
169
|
+
revision,
|
|
170
|
+
expiresAt: manifest.expiresAt,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
let copied = false;
|
|
174
|
+
if (!opts.noClipboard && deps.clipboard) {
|
|
175
|
+
copied = await deps.clipboard(url);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (opts.json) {
|
|
179
|
+
ui.out(JSON.stringify({
|
|
180
|
+
id,
|
|
181
|
+
url,
|
|
182
|
+
revision,
|
|
183
|
+
expiresAt: manifest.expiresAt,
|
|
184
|
+
fingerprint,
|
|
185
|
+
size: manifest.totalSize,
|
|
186
|
+
files: manifest.files.map((f) => f.path),
|
|
187
|
+
}));
|
|
188
|
+
} else if (opts.quiet) {
|
|
189
|
+
ui.out(url);
|
|
190
|
+
} else {
|
|
191
|
+
ui.out('');
|
|
192
|
+
ui.ok('Uploaded as a secret gist (unlisted — anyone with the link can read it)');
|
|
193
|
+
if (copied) ui.ok(`Link copied to clipboard · advisory expiry in ${expires.label}`);
|
|
194
|
+
else ui.out(` Advisory expiry in ${expires.label}`);
|
|
195
|
+
ui.out('');
|
|
196
|
+
ui.out(` ${url}`);
|
|
197
|
+
ui.out('');
|
|
198
|
+
ui.out(' They run: skillshark install <the link> (no GitHub account needed)');
|
|
199
|
+
ui.out(` Undo: skillshark revoke ${manifest.name} (deletes the gist)`);
|
|
200
|
+
}
|
|
201
|
+
return { status: 'shared', id, url, fingerprint };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// --- revoke (§4.4) -----------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
export async function runRevoke(idOrName, opts, deps) {
|
|
207
|
+
const ui = deps.ui;
|
|
208
|
+
let id = null;
|
|
209
|
+
let label = idOrName;
|
|
210
|
+
if (/^[0-9a-f]{20,32}$/.test(idOrName)) {
|
|
211
|
+
id = idOrName;
|
|
212
|
+
} else {
|
|
213
|
+
const rec = await findShareRecord(deps.configDir, idOrName);
|
|
214
|
+
if (rec) {
|
|
215
|
+
id = rec.id;
|
|
216
|
+
label = `${rec.name} (${rec.id})`;
|
|
217
|
+
} else {
|
|
218
|
+
// cache miss → ask gh for our skillshark gists
|
|
219
|
+
const out = await deps.ghApi(['gists', '--paginate']);
|
|
220
|
+
let gists;
|
|
221
|
+
try {
|
|
222
|
+
gists = JSON.parse(out);
|
|
223
|
+
} catch {
|
|
224
|
+
throw new CliError('Unexpected response from gh while listing gists.', 1);
|
|
225
|
+
}
|
|
226
|
+
const mine = gists.filter((g) => (g.description ?? '').startsWith(`skillshark: ${idOrName} (`));
|
|
227
|
+
if (mine.length === 0) {
|
|
228
|
+
throw new CliError(`No share named "${idOrName}" found (locally or in your gists).`, 2);
|
|
229
|
+
}
|
|
230
|
+
mine.sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)));
|
|
231
|
+
id = mine[0].id;
|
|
232
|
+
label = `${idOrName} (${id})`;
|
|
233
|
+
if (mine.length > 1) ui.warn(`${mine.length} shares named "${idOrName}" exist; revoking the newest. Re-run with the gist id for the others.`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (deps.isTTY && !opts.yes) {
|
|
238
|
+
const go = await deps.prompts.confirm({ message: `Delete the gist for ${label}? Anyone holding the link loses access.` });
|
|
239
|
+
if (go !== true) {
|
|
240
|
+
ui.out(' Cancelled.');
|
|
241
|
+
return { status: 'cancelled' };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
await deleteGist(id, { ghApi: deps.ghApi });
|
|
245
|
+
await removeShareRecord(deps.configDir, id);
|
|
246
|
+
if (opts.json) ui.out(JSON.stringify({ revoked: id }));
|
|
247
|
+
else ui.ok(`Revoked — the gist ${id} is gone. Anyone holding the link now gets "deleted by the sender".`);
|
|
248
|
+
return { status: 'revoked', id };
|
|
249
|
+
}
|
package/src/source.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// §7.3 — source parsing. Accepted forms:
|
|
2
|
+
// https://gist.github.com/<id> (optionally <user>/<id>, optionally #fp=<hex>)
|
|
3
|
+
// <20-32 char hex gist id>
|
|
4
|
+
// gh:owner/repo[/deep/path][@ref] (ref = branch, tag, or SHA; split on the LAST "@")
|
|
5
|
+
import { CliError } from './errors.js';
|
|
6
|
+
|
|
7
|
+
const USAGE = `Unrecognized source. Accepted forms:
|
|
8
|
+
https://gist.github.com/8a1bc94ef23d4b6a9c01e57f8d2a4b3c#fp=3f9a7c21
|
|
9
|
+
8a1bc94ef23d4b6a9c01e57f8d2a4b3c
|
|
10
|
+
gh:owner/repo[/deep/path][@ref]`;
|
|
11
|
+
|
|
12
|
+
const NAME_RE = /^[A-Za-z0-9_.-]+$/;
|
|
13
|
+
|
|
14
|
+
export function parseSource(input) {
|
|
15
|
+
const s = String(input ?? '').trim();
|
|
16
|
+
if (!s) throw new CliError(USAGE, 2);
|
|
17
|
+
|
|
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}))?$/,
|
|
20
|
+
);
|
|
21
|
+
if (gistUrl) {
|
|
22
|
+
return { kind: 'gist', id: gistUrl[2], fp: gistUrl[3] ?? null };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (/^[0-9a-f]{20,32}$/.test(s)) {
|
|
26
|
+
return { kind: 'gist', id: s, fp: null };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (s.startsWith('gh:')) {
|
|
30
|
+
let rest = s.slice(3);
|
|
31
|
+
let ref = null;
|
|
32
|
+
const at = rest.lastIndexOf('@');
|
|
33
|
+
if (at !== -1) {
|
|
34
|
+
ref = rest.slice(at + 1);
|
|
35
|
+
rest = rest.slice(0, at);
|
|
36
|
+
if (!ref) throw new CliError(USAGE, 2);
|
|
37
|
+
}
|
|
38
|
+
const segs = rest.split('/').filter(Boolean);
|
|
39
|
+
if (segs.length >= 2 && NAME_RE.test(segs[0]) && NAME_RE.test(segs[1])) {
|
|
40
|
+
return {
|
|
41
|
+
kind: 'repo',
|
|
42
|
+
owner: segs[0],
|
|
43
|
+
repo: segs[1],
|
|
44
|
+
path: segs.length > 2 ? segs.slice(2).join('/') : null,
|
|
45
|
+
ref,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
throw new CliError(`Unrecognized source: "${s}"\n${USAGE.split('\n').slice(1).join('\n')}`, 2);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function formatSource(src) {
|
|
54
|
+
if (src.kind === 'gist') return `gist:${src.id}`;
|
|
55
|
+
const p = src.path ? `/${src.path}` : '';
|
|
56
|
+
const r = src.ref ? `@${src.ref}` : '';
|
|
57
|
+
return `gh:${src.owner}/${src.repo}${p}${r}`;
|
|
58
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
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).
|
|
3
|
+
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { CliError, MSG } from '../errors.js';
|
|
7
|
+
import { USER_AGENT } from '../version.js';
|
|
8
|
+
|
|
9
|
+
export const GIST_PAYLOAD_LIMIT = 5 * 1024 * 1024; // encoded bytes (§4.1)
|
|
10
|
+
const FETCH_HEADERS = {
|
|
11
|
+
Accept: 'application/vnd.github+json',
|
|
12
|
+
'User-Agent': USER_AGENT,
|
|
13
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
14
|
+
// anonymous API responses are CDN-cached; a revoked gist must die promptly
|
|
15
|
+
'Cache-Control': 'no-cache',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// --- sender side (gh) -------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export function gistDescription({ name, agent, type, fp8 }) {
|
|
21
|
+
const kind = agent ? `${agent} ${type}` : type;
|
|
22
|
+
return `skillshark: ${name} (${kind}) · fp ${fp8}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// One `gh api gists --method POST --input <tmp.json>` call; a JSON body file
|
|
26
|
+
// avoids every shell-escaping pitfall (hard rule 3).
|
|
27
|
+
export async function createGist({ manifestJson, primaryDoc, tarballB64, description, ghApi }) {
|
|
28
|
+
const files = { 'SKILLSHARK.json': { content: manifestJson } };
|
|
29
|
+
if (primaryDoc && primaryDoc.content.trim()) {
|
|
30
|
+
files[path.basename(primaryDoc.name)] = { content: primaryDoc.content };
|
|
31
|
+
}
|
|
32
|
+
files['package.tgz.b64'] = { content: tarballB64 };
|
|
33
|
+
const body = { public: false, description, files };
|
|
34
|
+
|
|
35
|
+
const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'skillshark-share-'));
|
|
36
|
+
const bodyFile = path.join(tmpDir, 'gist-body.json');
|
|
37
|
+
try {
|
|
38
|
+
await writeFile(bodyFile, JSON.stringify(body));
|
|
39
|
+
const stdout = await ghApi(['gists', '--method', 'POST', '--input', bodyFile]);
|
|
40
|
+
let parsed;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(stdout);
|
|
43
|
+
} catch {
|
|
44
|
+
throw new CliError('Unexpected response from gh while creating the gist.', 1);
|
|
45
|
+
}
|
|
46
|
+
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 };
|
|
48
|
+
} finally {
|
|
49
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function deleteGist(id, { ghApi }) {
|
|
54
|
+
await ghApi(['--method', 'DELETE', `gists/${id}`]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- receive side (anonymous fetch, no gh — ever) ---------------------------
|
|
58
|
+
|
|
59
|
+
async function readBodyCapped(res, cap, what) {
|
|
60
|
+
const chunks = [];
|
|
61
|
+
let total = 0;
|
|
62
|
+
for await (const chunk of res.body) {
|
|
63
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
64
|
+
total += buf.length;
|
|
65
|
+
if (total > cap) {
|
|
66
|
+
throw new CliError(`${what} exceeds the ${Math.floor(cap / (1024 * 1024))} MB limit. Refusing to continue.`, 1);
|
|
67
|
+
}
|
|
68
|
+
chunks.push(buf);
|
|
69
|
+
}
|
|
70
|
+
return Buffer.concat(chunks);
|
|
71
|
+
}
|
|
72
|
+
|
|
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);
|
|
83
|
+
}
|
|
84
|
+
if (!res.ok) throw new CliError(`GitHub API error fetching the gist (HTTP ${res.status}).`, 1);
|
|
85
|
+
const data = await res.json();
|
|
86
|
+
|
|
87
|
+
const pkgFile = data.files?.['package.tgz.b64'];
|
|
88
|
+
if (!pkgFile) {
|
|
89
|
+
throw new CliError('No package at that link (the gist has no package.tgz.b64 — not a SkillShark share).', 1);
|
|
90
|
+
}
|
|
91
|
+
let b64;
|
|
92
|
+
if (pkgFile.truncated) {
|
|
93
|
+
let raw;
|
|
94
|
+
try {
|
|
95
|
+
raw = await fetch(pkgFile.raw_url, { headers: { 'User-Agent': USER_AGENT } });
|
|
96
|
+
} catch (e) {
|
|
97
|
+
throw new CliError(`Network error fetching the package: ${e.message}`, 1);
|
|
98
|
+
}
|
|
99
|
+
if (!raw.ok) throw new CliError(`Could not fetch the package payload (HTTP ${raw.status}).`, 1);
|
|
100
|
+
b64 = (await readBodyCapped(raw, GIST_PAYLOAD_LIMIT + 1024 * 1024, 'The package payload')).toString('utf8');
|
|
101
|
+
} else {
|
|
102
|
+
b64 = pkgFile.content ?? '';
|
|
103
|
+
}
|
|
104
|
+
if (b64.length > GIST_PAYLOAD_LIMIT + 1024 * 1024) {
|
|
105
|
+
throw new CliError('The package payload exceeds the gist size limit. Refusing to continue.', 1);
|
|
106
|
+
}
|
|
107
|
+
const tarball = Buffer.from(b64.replace(/\s+/g, ''), 'base64');
|
|
108
|
+
if (tarball.length < 2 || tarball[0] !== 0x1f || tarball[1] !== 0x8b) {
|
|
109
|
+
throw new CliError(MSG.downloadIntegrity, 1);
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
tarball,
|
|
113
|
+
owner: data.owner?.login ?? null,
|
|
114
|
+
revision: data.history?.[0]?.version ?? null,
|
|
115
|
+
description: data.description ?? '',
|
|
116
|
+
};
|
|
117
|
+
}
|