shary-artifacts 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.
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { realpathSync } from 'node:fs';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { runCli } from '../src/cli.js';
6
+
7
+ const isMain = pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url;
8
+
9
+ if (isMain) {
10
+ runCli(process.argv).catch((error) => {
11
+ console.error(error?.message ?? String(error));
12
+ process.exit(1);
13
+ });
14
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "shary-artifacts",
3
+ "version": "0.1.0",
4
+ "description": "CLI for publishing self-contained artifacts to Shary.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "shary-artifacts": "bin/shary-artifacts.js"
9
+ },
10
+ "main": "src/cli.js",
11
+ "files": [
12
+ "bin",
13
+ "src"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18.0.0"
17
+ },
18
+ "scripts": {
19
+ "test": "vitest run",
20
+ "test:coverage": "vitest run --coverage"
21
+ },
22
+ "dependencies": {
23
+ "commander": "^12.1.0"
24
+ },
25
+ "devDependencies": {
26
+ "@vitest/coverage-v8": "^2.1.4",
27
+ "vitest": "^2.1.4"
28
+ }
29
+ }
package/src/cli.js ADDED
@@ -0,0 +1,113 @@
1
+ import { Command, Option } from 'commander';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { readFileSync } from 'node:fs';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { inlineHtml } from './inline.js';
8
+ import { publishArtifact, upgradeArtifact } from './publish.js';
9
+ import { checkForUpdates } from './update-check.js';
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
13
+
14
+ function applyGlobalOptions(command) {
15
+ command.option('--server <url>', 'Shary server URL', 'https://shary.cc');
16
+ command.addOption(
17
+ new Option('--api-key <key>', 'Shary API key').env('SHARY_API_KEY')
18
+ );
19
+ }
20
+
21
+ export async function runCli(argv) {
22
+ await checkForUpdates(pkg.version);
23
+
24
+ const program = new Command();
25
+ program
26
+ .name('shary')
27
+ .description('CLI for publishing self-contained artifacts to Shary.')
28
+ .version(pkg.version)
29
+ .exitOverride();
30
+
31
+ applyGlobalOptions(program);
32
+
33
+ program
34
+ .command('inline <input>')
35
+ .description('Inline local assets into a self-contained HTML file.')
36
+ .requiredOption('-o, --output <path>', 'Output file path')
37
+ .action(async (input, options, command) => {
38
+ const inlined = await inlineHtml(input, options.output);
39
+ const size = Buffer.byteLength(inlined, 'utf8');
40
+ console.log(`Wrote ${options.output} (${size} bytes)`);
41
+ });
42
+
43
+ program
44
+ .command('publish <input>')
45
+ .description('Inline assets and publish the file to Shary.')
46
+ .requiredOption('-t, --title <title>', 'Artifact title')
47
+ .action(async (input, options, command) => {
48
+ const apiKey = getApiKey(command);
49
+ const server = command.optsWithGlobals().server;
50
+ const tmpFile = await inlineToTemp(input);
51
+ try {
52
+ const result = await publishArtifact(server, apiKey, tmpFile, options.title);
53
+ console.log(result.url);
54
+ } finally {
55
+ await cleanup(tmpFile);
56
+ }
57
+ });
58
+
59
+ program
60
+ .command('upgrade <slug> <input>')
61
+ .description('Inline assets and upgrade an existing artifact.')
62
+ .option('-t, --title <title>', 'New artifact title')
63
+ .action(async (slug, input, options, command) => {
64
+ const apiKey = getApiKey(command);
65
+ const server = command.optsWithGlobals().server;
66
+ const tmpFile = await inlineToTemp(input);
67
+ try {
68
+ const result = await upgradeArtifact(server, apiKey, slug, tmpFile, options.title);
69
+ console.log(result.url);
70
+ } finally {
71
+ await cleanup(tmpFile);
72
+ }
73
+ });
74
+
75
+ program.commands.forEach(applyGlobalOptions);
76
+
77
+ try {
78
+ await program.parseAsync(argv);
79
+ } catch (error) {
80
+ if (error?.name === 'CommanderError') {
81
+ if (error.code === 'commander.help' || error.code === 'commander.helpDisplayed' || error.code === 'commander.version') {
82
+ return 0;
83
+ }
84
+ return error.exitCode ?? 1;
85
+ }
86
+ throw error;
87
+ }
88
+
89
+ return 0;
90
+ }
91
+
92
+ function getApiKey(command) {
93
+ const apiKey = command.optsWithGlobals().apiKey;
94
+ if (!apiKey) {
95
+ throw new Error('API key is required. Set SHARY_API_KEY or use --api-key.');
96
+ }
97
+ return apiKey;
98
+ }
99
+
100
+ async function inlineToTemp(input) {
101
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'shary-'));
102
+ const tmpFile = path.join(tmpDir, 'artifact.html');
103
+ await inlineHtml(input, tmpFile);
104
+ return tmpFile;
105
+ }
106
+
107
+ async function cleanup(tmpFile) {
108
+ try {
109
+ await fs.rm(path.dirname(tmpFile), { recursive: true, force: true });
110
+ } catch {
111
+ // Best-effort cleanup.
112
+ }
113
+ }
package/src/inline.js ADDED
@@ -0,0 +1,306 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ const SIXTEEN_MIB = 16 * 1024 * 1024;
5
+
6
+ const MIME_TYPES = {
7
+ '.css': 'text/css',
8
+ '.js': 'application/javascript',
9
+ '.mjs': 'application/javascript',
10
+ '.png': 'image/png',
11
+ '.jpg': 'image/jpeg',
12
+ '.jpeg': 'image/jpeg',
13
+ '.gif': 'image/gif',
14
+ '.svg': 'image/svg+xml',
15
+ '.webp': 'image/webp',
16
+ '.ico': 'image/x-icon',
17
+ '.woff': 'font/woff',
18
+ '.woff2': 'font/woff2',
19
+ '.ttf': 'font/ttf',
20
+ '.otf': 'font/otf',
21
+ '.eot': 'application/vnd.ms-fontobject',
22
+ '.html': 'text/html',
23
+ '.md': 'text/markdown',
24
+ };
25
+
26
+ const FONT_EXTENSIONS = new Set(['.woff', '.woff2', '.ttf', '.otf', '.eot']);
27
+
28
+ export async function inlineHtml(inputPath, outputPath) {
29
+ const absInput = path.resolve(inputPath);
30
+ const html = await fs.readFile(absInput, 'utf8');
31
+ const baseDir = path.dirname(absInput);
32
+ const inlined = await inlineHtmlString(html, baseDir);
33
+
34
+ if (outputPath) {
35
+ await fs.writeFile(path.resolve(outputPath), inlined, 'utf8');
36
+ }
37
+
38
+ return inlined;
39
+ }
40
+
41
+ export async function inlineHtmlString(html, baseDir) {
42
+ const externalRefs = new Set();
43
+ let result = html;
44
+
45
+ result = await inlineStylesheets(result, baseDir, externalRefs);
46
+ result = await inlineScripts(result, baseDir, externalRefs);
47
+ result = await inlineImages(result, baseDir);
48
+ result = await inlineFontLinks(result, baseDir);
49
+ result = await inlineStyleBlocks(result, baseDir);
50
+
51
+ findExternalUrls(result).forEach((url) => externalRefs.add(url));
52
+ warnRemainingExternal(externalRefs);
53
+ warnIfOversized(result);
54
+
55
+ return result;
56
+ }
57
+
58
+ async function inlineStylesheets(html, baseDir, externalRefs) {
59
+ const linkRegex = /<link\b[^>]*?>/gi;
60
+ const matches = [...html.matchAll(linkRegex)];
61
+ if (!matches.length) return html;
62
+
63
+ const replacements = await Promise.all(
64
+ matches.map(async (match) => {
65
+ const tag = match[0];
66
+ const attrs = parseAttributes(tag);
67
+ if (!/stylesheet/i.test(attrs.rel ?? '')) return tag;
68
+ const href = attrs.href;
69
+ if (!href) return tag;
70
+
71
+ const cssPath = resolveLocalPath(href, baseDir);
72
+ if (!cssPath) return tag;
73
+
74
+ let css;
75
+ try {
76
+ css = await fs.readFile(cssPath, 'utf8');
77
+ } catch (error) {
78
+ warn(`Could not read stylesheet ${href}: ${error.message}`);
79
+ return tag;
80
+ }
81
+
82
+ const cssDir = path.dirname(cssPath);
83
+ const inlinedCss = await inlineUrlsInCss(css, cssDir);
84
+ findExternalUrls(inlinedCss).forEach((url) => externalRefs.add(url));
85
+ const dataUri = await contentToDataUri(inlinedCss, cssPath);
86
+ return replaceAttribute(tag, 'href', dataUri);
87
+ })
88
+ );
89
+
90
+ let index = 0;
91
+ return html.replace(linkRegex, () => replacements[index++]);
92
+ }
93
+
94
+ async function inlineScripts(html, baseDir, externalRefs) {
95
+ const scriptRegex = /<script\b[^>]*?>(?:[\s\S]*?)<\/script>/gi;
96
+ const matches = [...html.matchAll(scriptRegex)];
97
+ if (!matches.length) return html;
98
+
99
+ const replacements = await Promise.all(
100
+ matches.map(async (match) => {
101
+ const tag = match[0];
102
+ const attrs = parseAttributes(tag);
103
+ const src = attrs.src;
104
+ if (!src) return tag;
105
+
106
+ const scriptPath = resolveLocalPath(src, baseDir);
107
+ if (!scriptPath) return tag;
108
+
109
+ let content;
110
+ try {
111
+ content = await fs.readFile(scriptPath, 'utf8');
112
+ } catch (error) {
113
+ warn(`Could not read script ${src}: ${error.message}`);
114
+ return tag;
115
+ }
116
+
117
+ findExternalUrls(content).forEach((url) => externalRefs.add(url));
118
+ const dataUri = await contentToDataUri(content, scriptPath);
119
+ return replaceAttribute(tag, 'src', dataUri);
120
+ })
121
+ );
122
+
123
+ let index = 0;
124
+ return html.replace(scriptRegex, () => replacements[index++]);
125
+ }
126
+
127
+ async function inlineImages(html, baseDir) {
128
+ const imgRegex = /<img\b[^>]*?>/gi;
129
+ const matches = [...html.matchAll(imgRegex)];
130
+ if (!matches.length) return html;
131
+
132
+ const replacements = await Promise.all(
133
+ matches.map(async (match) => {
134
+ const tag = match[0];
135
+ const attrs = parseAttributes(tag);
136
+ const src = attrs.src;
137
+ if (!src) return tag;
138
+
139
+ const dataUri = await assetToDataUri(src, baseDir);
140
+ if (!dataUri) return tag;
141
+
142
+ return replaceAttribute(tag, 'src', dataUri);
143
+ })
144
+ );
145
+
146
+ let index = 0;
147
+ return html.replace(imgRegex, () => replacements[index++]);
148
+ }
149
+
150
+ async function inlineFontLinks(html, baseDir) {
151
+ const linkRegex = /<link\b[^>]*?>/gi;
152
+ const matches = [...html.matchAll(linkRegex)];
153
+ if (!matches.length) return html;
154
+
155
+ const replacements = await Promise.all(
156
+ matches.map(async (match) => {
157
+ const tag = match[0];
158
+ const attrs = parseAttributes(tag);
159
+ const href = attrs.href;
160
+ if (!href) return tag;
161
+
162
+ const ext = path.extname(stripQueryAndHash(href)).toLowerCase();
163
+ if (!FONT_EXTENSIONS.has(ext)) return tag;
164
+
165
+ const dataUri = await assetToDataUri(href, baseDir);
166
+ if (!dataUri) return tag;
167
+
168
+ return replaceAttribute(tag, 'href', dataUri);
169
+ })
170
+ );
171
+
172
+ let index = 0;
173
+ return html.replace(linkRegex, () => replacements[index++]);
174
+ }
175
+
176
+ async function inlineStyleBlocks(html, baseDir) {
177
+ const styleRegex = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
178
+ const matches = [...html.matchAll(styleRegex)];
179
+ if (!matches.length) return html;
180
+
181
+ const replacements = await Promise.all(
182
+ matches.map(async (match) => {
183
+ const fullTag = match[0];
184
+ const css = match[1];
185
+ const inlinedCss = await inlineUrlsInCss(css, baseDir);
186
+ return fullTag.replace(css, inlinedCss);
187
+ })
188
+ );
189
+
190
+ let index = 0;
191
+ return html.replace(styleRegex, () => replacements[index++]);
192
+ }
193
+
194
+ async function inlineUrlsInCss(css, baseDir) {
195
+ const urlRegex = /url\(\s*(['"]?)([^'"\n)]+)\1\s*\)/g;
196
+ const matches = [...css.matchAll(urlRegex)];
197
+ if (!matches.length) return css;
198
+
199
+ const replacements = await Promise.all(
200
+ matches.map(async (match) => {
201
+ const url = match[2];
202
+ const dataUri = await assetToDataUri(url, baseDir);
203
+ if (!dataUri) return match[0];
204
+ return `url("${dataUri}")`;
205
+ })
206
+ );
207
+
208
+ let index = 0;
209
+ return css.replace(urlRegex, () => replacements[index++]);
210
+ }
211
+
212
+ async function assetToDataUri(url, baseDir) {
213
+ if (isExternal(url)) return null;
214
+
215
+ const assetPath = resolveLocalPath(url, baseDir);
216
+ if (!assetPath) return null;
217
+
218
+ let buffer;
219
+ try {
220
+ buffer = await fs.readFile(assetPath);
221
+ } catch (error) {
222
+ warn(`Could not read asset ${url}: ${error.message}`);
223
+ return null;
224
+ }
225
+
226
+ const mime = mimeTypeFor(assetPath);
227
+ const base64 = buffer.toString('base64');
228
+ return `data:${mime};base64,${base64}`;
229
+ }
230
+
231
+ function resolveLocalPath(url, baseDir) {
232
+ const clean = stripQueryAndHash(url);
233
+ if (!clean || clean.startsWith('/') || /^[a-z][a-z0-9+.-]*:/i.test(clean)) {
234
+ return null;
235
+ }
236
+ return path.resolve(baseDir, clean);
237
+ }
238
+
239
+ function isExternal(url) {
240
+ return /^https?:\/\//i.test(url) || /^\/\//i.test(url);
241
+ }
242
+
243
+ function stripQueryAndHash(url) {
244
+ return url.split('#')[0].split('?')[0];
245
+ }
246
+
247
+ function mimeTypeFor(filePath) {
248
+ const ext = path.extname(filePath).toLowerCase();
249
+ return MIME_TYPES[ext] ?? 'application/octet-stream';
250
+ }
251
+
252
+ function parseAttributes(tag) {
253
+ const attrs = {};
254
+ const regex = /(\w[-\w]*)=["']([^"']*)["']/g;
255
+ let match;
256
+ while ((match = regex.exec(tag)) !== null) {
257
+ attrs[match[1].toLowerCase()] = match[2];
258
+ }
259
+ return attrs;
260
+ }
261
+
262
+ function replaceAttribute(tag, name, value) {
263
+ const regex = new RegExp(`(${escapeRegExp(name)}=)["'][^"']*["']`, 'i');
264
+ return tag.replace(regex, `$1"${value}"`);
265
+ }
266
+
267
+ function escapeRegExp(string) {
268
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
269
+ }
270
+
271
+ function findExternalUrls(text) {
272
+ const regex = /https?:\/\/[^\s"')>]+/g;
273
+ const refs = new Set();
274
+ let match;
275
+ while ((match = regex.exec(text)) !== null) {
276
+ refs.add(match[0]);
277
+ }
278
+ return refs;
279
+ }
280
+
281
+ async function contentToDataUri(content, filePath) {
282
+ const mime = mimeTypeFor(filePath);
283
+ const base64 = Buffer.from(content, 'utf8').toString('base64');
284
+ return `data:${mime};base64,${base64}`;
285
+ }
286
+
287
+ function warnRemainingExternal(refs) {
288
+ if (refs.size > 0) {
289
+ warn(
290
+ `Warning: ${refs.size} external HTTP(S) reference(s) remain after inlining: ${[...refs].join(', ')}`
291
+ );
292
+ }
293
+ }
294
+
295
+ function warnIfOversized(html) {
296
+ const bytes = Buffer.byteLength(html, 'utf8');
297
+ if (bytes > SIXTEEN_MIB) {
298
+ warn(
299
+ `Warning: output is ${bytes} bytes, which exceeds the 16 MiB limit enforced by the Shary server.`
300
+ );
301
+ }
302
+ }
303
+
304
+ function warn(message) {
305
+ console.error(message);
306
+ }
package/src/publish.js ADDED
@@ -0,0 +1,71 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export async function publishArtifact(serverUrl, apiKey, filePath, title) {
5
+ const url = new URL('/api/artifacts', serverUrl).toString();
6
+ return upload(url, apiKey, filePath, { title });
7
+ }
8
+
9
+ export async function upgradeArtifact(serverUrl, apiKey, slug, filePath, title) {
10
+ const url = new URL(`/api/artifacts/${encodeURIComponent(slug)}`, serverUrl).toString();
11
+ const fields = {};
12
+ if (title !== undefined && title !== null && title !== '') {
13
+ fields.title = title;
14
+ }
15
+ return upload(url, apiKey, filePath, fields);
16
+ }
17
+
18
+ async function upload(url, apiKey, filePath, fields) {
19
+ const buffer = await fs.readFile(filePath);
20
+ const form = new FormData();
21
+ form.append('file', new Blob([buffer]), path.basename(filePath));
22
+ for (const [key, value] of Object.entries(fields)) {
23
+ form.append(key, value);
24
+ }
25
+
26
+ const response = await fetch(url, {
27
+ method: 'POST',
28
+ headers: {
29
+ Authorization: `Bearer ${apiKey}`,
30
+ },
31
+ body: form,
32
+ });
33
+
34
+ const text = await response.text();
35
+ let body;
36
+ try {
37
+ body = JSON.parse(text);
38
+ } catch {
39
+ body = { message: text };
40
+ }
41
+
42
+ if (!response.ok) {
43
+ const message = formatError(body, text, response.status);
44
+ throw new Error(message);
45
+ }
46
+
47
+ if (!body.url) {
48
+ throw new Error('Upload succeeded but the response did not contain a URL.');
49
+ }
50
+
51
+ return body;
52
+ }
53
+
54
+ function formatError(body, rawText, status) {
55
+ const message = body?.message || rawText || `HTTP ${status}`;
56
+ const issues = body?.issues;
57
+
58
+ if (!Array.isArray(issues) || issues.length === 0) {
59
+ return `Upload failed: ${message}`;
60
+ }
61
+
62
+ const lines = [message];
63
+ for (const issue of issues) {
64
+ lines.push(`- ${issue.message}`);
65
+ if (issue.fix) {
66
+ lines.push(` Fix: ${issue.fix}`);
67
+ }
68
+ }
69
+
70
+ return lines.join('\n');
71
+ }
@@ -0,0 +1,80 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ const REGISTRY_URL = 'https://registry.npmjs.org/shary-artifacts/latest';
6
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
7
+
8
+ export async function checkForUpdates(currentVersion) {
9
+ if (!currentVersion) return;
10
+
11
+ const configDir = getConfigDir();
12
+ const cacheFile = path.join(configDir, 'version-check.json');
13
+
14
+ let cached;
15
+ try {
16
+ cached = JSON.parse(await fs.readFile(cacheFile, 'utf8'));
17
+ } catch {
18
+ cached = null;
19
+ }
20
+
21
+ if (cached?.checkedAt && cached?.latest && Date.now() - cached.checkedAt < CACHE_TTL_MS) {
22
+ warnIfOutdated(currentVersion, cached.latest);
23
+ return;
24
+ }
25
+
26
+ try {
27
+ const response = await fetch(REGISTRY_URL, {
28
+ headers: { accept: 'application/json' },
29
+ });
30
+ if (!response.ok) return;
31
+
32
+ const data = await response.json();
33
+ const latest = data.version;
34
+ if (!latest) return;
35
+
36
+ await fs.mkdir(configDir, { recursive: true });
37
+ await fs.writeFile(
38
+ cacheFile,
39
+ JSON.stringify({ checkedAt: Date.now(), latest }),
40
+ 'utf8'
41
+ );
42
+
43
+ warnIfOutdated(currentVersion, latest);
44
+ } catch {
45
+ // Network or registry errors are intentionally ignored so the CLI keeps working offline.
46
+ }
47
+ }
48
+
49
+ function getConfigDir() {
50
+ const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
51
+ return path.join(base, 'shary-artifacts');
52
+ }
53
+
54
+ function warnIfOutdated(current, latest) {
55
+ if (isNewer(latest, current)) {
56
+ console.error(
57
+ `A new version of shary-artifacts is available: ${latest} (you have ${current}). Run with npx shary-artifacts@latest to update.`
58
+ );
59
+ }
60
+ }
61
+
62
+ function isNewer(latest, current) {
63
+ const parse = (version) =>
64
+ version
65
+ .replace(/^v/, '')
66
+ .split('.')
67
+ .map((part) => parseInt(part, 10) || 0);
68
+
69
+ const l = parse(latest);
70
+ const c = parse(current);
71
+
72
+ for (let i = 0; i < 3; i++) {
73
+ const li = l[i] ?? 0;
74
+ const ci = c[i] ?? 0;
75
+ if (li > ci) return true;
76
+ if (li < ci) return false;
77
+ }
78
+
79
+ return false;
80
+ }