shartifacts 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/dist/bin.js +3 -0
- package/dist/cli.js +328 -0
- package/package.json +19 -0
package/dist/bin.js
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { readFile, writeFile, mkdir, rm, chmod } from 'node:fs/promises';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { hostname, platform } from 'node:os';
|
|
5
|
+
import { join, basename } from 'node:path';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
export const DEFAULT_HOST = 'https://shartifacts.vercel.app';
|
|
8
|
+
export const RULES = `## shartifacts
|
|
9
|
+
|
|
10
|
+
Share an HTML page with teammates and read their comments.
|
|
11
|
+
|
|
12
|
+
- Publish: \`shartifacts publish page.html --title "Title"\` → prints id,
|
|
13
|
+
url, passphrase. Give the user the url and the passphrase separately;
|
|
14
|
+
they send the passphrase over a different channel than the link.
|
|
15
|
+
- Update the same page: \`shartifacts publish page.html --id <id>\`.
|
|
16
|
+
- Read feedback: \`shartifacts comments <id>\`.
|
|
17
|
+
- The page renders in a sandboxed iframe with an opaque origin: no
|
|
18
|
+
\`localStorage\`, no \`document.cookie\`, no same-origin fetches. Inline all
|
|
19
|
+
CSS and JS; external scripts only from cdnjs or jsdelivr.
|
|
20
|
+
- One self-contained file. Pick the smallest visual that makes the point.
|
|
21
|
+
`;
|
|
22
|
+
const START = '<!-- shartifacts:start -->';
|
|
23
|
+
const END = '<!-- shartifacts:end -->';
|
|
24
|
+
export function initBlock(existing) {
|
|
25
|
+
const block = `${START}\n${RULES.trim()}\n${END}\n`;
|
|
26
|
+
const s = existing.indexOf(START);
|
|
27
|
+
const e = existing.indexOf(END);
|
|
28
|
+
if (s !== -1 && e !== -1 && e > s) {
|
|
29
|
+
return existing.slice(0, s) + block + existing.slice(e + END.length).replace(/^\n/, '');
|
|
30
|
+
}
|
|
31
|
+
const sep = existing === '' || existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n';
|
|
32
|
+
return existing + sep + block;
|
|
33
|
+
}
|
|
34
|
+
export function pickAgentsFile(dir) {
|
|
35
|
+
const agents = join(dir, 'AGENTS.md');
|
|
36
|
+
const claude = join(dir, 'CLAUDE.md');
|
|
37
|
+
if (existsSync(agents))
|
|
38
|
+
return agents;
|
|
39
|
+
if (existsSync(claude))
|
|
40
|
+
return claude;
|
|
41
|
+
return agents;
|
|
42
|
+
}
|
|
43
|
+
export function configPath(env = process.env) {
|
|
44
|
+
const base = env.XDG_CONFIG_HOME || join(env.HOME ?? '', '.config');
|
|
45
|
+
return join(base, 'shartifacts', 'config.json');
|
|
46
|
+
}
|
|
47
|
+
export async function readConfig(env = process.env) {
|
|
48
|
+
if (env.SHARTIFACTS_TOKEN) {
|
|
49
|
+
return {
|
|
50
|
+
host: (env.SHARTIFACTS_HOST || DEFAULT_HOST).replace(/\/$/, ''),
|
|
51
|
+
token: env.SHARTIFACTS_TOKEN,
|
|
52
|
+
login: '',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
let config;
|
|
56
|
+
try {
|
|
57
|
+
config = JSON.parse(await readFile(configPath(env), 'utf8'));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
config.host = (env.SHARTIFACTS_HOST || config.host).replace(/\/$/, '');
|
|
63
|
+
return config;
|
|
64
|
+
}
|
|
65
|
+
export async function writeConfig(config, env = process.env) {
|
|
66
|
+
const path = configPath(env);
|
|
67
|
+
await mkdir(join(path, '..'), { recursive: true, mode: 0o700 });
|
|
68
|
+
await writeFile(path, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
69
|
+
// writeFile's mode only applies when it creates the file; force it on overwrite too.
|
|
70
|
+
await chmod(path, 0o600);
|
|
71
|
+
}
|
|
72
|
+
export function format(obj, json) {
|
|
73
|
+
if (json)
|
|
74
|
+
return JSON.stringify(obj);
|
|
75
|
+
return Object.entries(obj)
|
|
76
|
+
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
|
77
|
+
.join('\n');
|
|
78
|
+
}
|
|
79
|
+
export function titleFromHtml(html, fallback) {
|
|
80
|
+
const m = /<title[^>]*>([^<]*)<\/title>/i.exec(html);
|
|
81
|
+
const t = m?.[1].trim();
|
|
82
|
+
return t || fallback;
|
|
83
|
+
}
|
|
84
|
+
class CliError extends Error {
|
|
85
|
+
// Parameter properties are non-erasable syntax: Node's type-stripping test
|
|
86
|
+
// runner (node --test src/*.ts) can't run them, so assign explicitly.
|
|
87
|
+
code;
|
|
88
|
+
constructor(message, code) {
|
|
89
|
+
super(message);
|
|
90
|
+
this.code = code;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function api(config, method, path, body) {
|
|
94
|
+
let res;
|
|
95
|
+
try {
|
|
96
|
+
res = await fetch(config.host + path, {
|
|
97
|
+
method,
|
|
98
|
+
headers: {
|
|
99
|
+
authorization: `Bearer ${config.token}`,
|
|
100
|
+
'content-type': 'application/json',
|
|
101
|
+
},
|
|
102
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
throw new CliError(`cannot reach ${config.host}: ${err.message}`, 3);
|
|
107
|
+
}
|
|
108
|
+
const data = (res.status === 204 ? {} : await res.json().catch(() => ({})));
|
|
109
|
+
if (res.status === 401)
|
|
110
|
+
throw new CliError('not logged in. Run: shartifacts login', 2);
|
|
111
|
+
if (!res.ok)
|
|
112
|
+
throw new CliError(data.error ?? `${res.status} ${res.statusText}`, 3);
|
|
113
|
+
return { status: res.status, data };
|
|
114
|
+
}
|
|
115
|
+
function openBrowser(url) {
|
|
116
|
+
const cmd = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'cmd' : 'xdg-open';
|
|
117
|
+
const args = platform() === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
118
|
+
try {
|
|
119
|
+
spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => { }).unref();
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Headless machine: the printed url is the fallback.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
126
|
+
async function requireConfig(env) {
|
|
127
|
+
const config = await readConfig(env);
|
|
128
|
+
if (!config)
|
|
129
|
+
throw new CliError('not logged in. Run: shartifacts login', 2);
|
|
130
|
+
return config;
|
|
131
|
+
}
|
|
132
|
+
const USAGE = `usage: shartifacts <command> [options]
|
|
133
|
+
|
|
134
|
+
login [--host URL] authorize this machine in the browser
|
|
135
|
+
logout forget the saved token
|
|
136
|
+
whoami who the saved token belongs to
|
|
137
|
+
publish <file> [--title T] [--id ID] create a page, or update one by id
|
|
138
|
+
list my pages
|
|
139
|
+
delete <id> delete one of my pages
|
|
140
|
+
comments <id> [--since ISO] read comments on one of my pages
|
|
141
|
+
rules print the instructions for agents
|
|
142
|
+
init add those instructions to AGENTS.md
|
|
143
|
+
|
|
144
|
+
--json on any command prints one JSON object instead of key: value lines`;
|
|
145
|
+
export async function main(argv, env = process.env) {
|
|
146
|
+
const out = (s) => process.stdout.write(s + '\n');
|
|
147
|
+
const err = (s) => process.stderr.write(s + '\n');
|
|
148
|
+
let parsed;
|
|
149
|
+
try {
|
|
150
|
+
parsed = parseArgs({
|
|
151
|
+
args: argv,
|
|
152
|
+
allowPositionals: true,
|
|
153
|
+
options: {
|
|
154
|
+
json: { type: 'boolean', default: false },
|
|
155
|
+
host: { type: 'string' },
|
|
156
|
+
title: { type: 'string' },
|
|
157
|
+
id: { type: 'string' },
|
|
158
|
+
since: { type: 'string' },
|
|
159
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
err(e.message);
|
|
165
|
+
err(USAGE);
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
const { values, positionals } = parsed;
|
|
169
|
+
const [command, arg] = positionals;
|
|
170
|
+
const json = values.json === true;
|
|
171
|
+
try {
|
|
172
|
+
switch (command) {
|
|
173
|
+
case 'login': {
|
|
174
|
+
const host = (values.host || env.SHARTIFACTS_HOST || DEFAULT_HOST).replace(/\/$/, '');
|
|
175
|
+
const startRes = await fetch(`${host}/api/device/start`, {
|
|
176
|
+
method: 'POST',
|
|
177
|
+
headers: { 'content-type': 'application/json' },
|
|
178
|
+
body: JSON.stringify({ label: hostname() }),
|
|
179
|
+
}).catch((e) => {
|
|
180
|
+
throw new CliError(`cannot reach ${host}: ${e.message}`, 3);
|
|
181
|
+
});
|
|
182
|
+
const start = (await startRes.json().catch(() => ({})));
|
|
183
|
+
if (!startRes.ok)
|
|
184
|
+
throw new CliError(start.error ?? `${startRes.status}`, 3);
|
|
185
|
+
err(`Open ${start.verify_url}`);
|
|
186
|
+
err(`and confirm the code: ${start.user_code}`);
|
|
187
|
+
// Only hand the OS a url the host we asked actually owns.
|
|
188
|
+
if (start.verify_url?.startsWith(host + '/'))
|
|
189
|
+
openBrowser(start.verify_url);
|
|
190
|
+
const deadline = Date.now() + start.expires_in * 1000;
|
|
191
|
+
while (Date.now() < deadline) {
|
|
192
|
+
await sleep(start.interval * 1000);
|
|
193
|
+
const poll = await fetch(`${host}/api/device/poll`, {
|
|
194
|
+
method: 'POST',
|
|
195
|
+
headers: { 'content-type': 'application/json' },
|
|
196
|
+
body: JSON.stringify({ poll_secret: start.poll_secret }),
|
|
197
|
+
}).catch(() => null);
|
|
198
|
+
if (!poll)
|
|
199
|
+
continue;
|
|
200
|
+
if (poll.status === 202)
|
|
201
|
+
continue;
|
|
202
|
+
if (poll.status === 410)
|
|
203
|
+
throw new CliError('login expired, run `shartifacts login` again', 2);
|
|
204
|
+
const data = (await poll.json());
|
|
205
|
+
if (!poll.ok || !data.token || !data.login)
|
|
206
|
+
throw new CliError(data.error ?? `${poll.status}`, 3);
|
|
207
|
+
await writeConfig({ host, token: data.token, login: data.login }, env);
|
|
208
|
+
out(json ? JSON.stringify({ login: data.login, host }) : `logged in as ${data.login}`);
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
throw new CliError('login expired, run `shartifacts login` again', 2);
|
|
212
|
+
}
|
|
213
|
+
case 'logout': {
|
|
214
|
+
await rm(configPath(env), { force: true });
|
|
215
|
+
out(format({ loggedOut: true }, json));
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
case 'whoami': {
|
|
219
|
+
const config = await requireConfig(env);
|
|
220
|
+
const { data } = await api(config, 'GET', '/api/me');
|
|
221
|
+
out(format({ login: data.login, artifacts: data.artifacts, host: config.host }, json));
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
case 'publish': {
|
|
225
|
+
if (!arg)
|
|
226
|
+
throw new CliError('usage: shartifacts publish <file.html> [--title T] [--id ID]', 1);
|
|
227
|
+
const config = await requireConfig(env);
|
|
228
|
+
const html = await readFile(arg, 'utf8').catch(() => {
|
|
229
|
+
throw new CliError(`cannot read ${arg}`, 1);
|
|
230
|
+
});
|
|
231
|
+
// An update sends a title only when one was asked for; otherwise the
|
|
232
|
+
// server keeps the title the page already has.
|
|
233
|
+
const body = { html };
|
|
234
|
+
if (values.id)
|
|
235
|
+
body.id = values.id;
|
|
236
|
+
if (values.title)
|
|
237
|
+
body.title = values.title;
|
|
238
|
+
else if (!values.id)
|
|
239
|
+
body.title = titleFromHtml(html, basename(arg));
|
|
240
|
+
const { data } = await api(config, 'POST', '/api/publish', body);
|
|
241
|
+
out(format(data, json));
|
|
242
|
+
return 0;
|
|
243
|
+
}
|
|
244
|
+
case 'list': {
|
|
245
|
+
const config = await requireConfig(env);
|
|
246
|
+
const { data } = await api(config, 'GET', '/api/artifacts');
|
|
247
|
+
if (json)
|
|
248
|
+
out(JSON.stringify(data));
|
|
249
|
+
else if (data.artifacts.length === 0)
|
|
250
|
+
out('no artifacts yet');
|
|
251
|
+
else
|
|
252
|
+
for (const a of data.artifacts)
|
|
253
|
+
out(`${a.id} ${a.title} ${a.url} ${a.comments} comments`);
|
|
254
|
+
return 0;
|
|
255
|
+
}
|
|
256
|
+
case 'delete': {
|
|
257
|
+
if (!arg)
|
|
258
|
+
throw new CliError('usage: shartifacts delete <id>', 1);
|
|
259
|
+
const config = await requireConfig(env);
|
|
260
|
+
await api(config, 'DELETE', `/api/artifacts/${encodeURIComponent(arg)}`);
|
|
261
|
+
out(format({ deleted: arg }, json));
|
|
262
|
+
return 0;
|
|
263
|
+
}
|
|
264
|
+
case 'comments': {
|
|
265
|
+
if (!arg)
|
|
266
|
+
throw new CliError('usage: shartifacts comments <id> [--since ISO]', 1);
|
|
267
|
+
const config = await requireConfig(env);
|
|
268
|
+
let query = '';
|
|
269
|
+
if (values.since) {
|
|
270
|
+
const t = Math.floor(Date.parse(values.since) / 1000);
|
|
271
|
+
if (!Number.isFinite(t))
|
|
272
|
+
throw new CliError('--since must be an ISO date', 1);
|
|
273
|
+
query = `?since=${t}`;
|
|
274
|
+
}
|
|
275
|
+
const { data } = await api(config, 'GET', `/api/c/${encodeURIComponent(arg)}${query}`);
|
|
276
|
+
if (json) {
|
|
277
|
+
out(JSON.stringify(data));
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
if (data.threads.length === 0) {
|
|
281
|
+
out('no comments');
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
const when = (t) => new Date(t * 1000).toISOString();
|
|
285
|
+
for (const t of data.threads) {
|
|
286
|
+
out(`#${t.id} ${t.author} ${when(t.created_at)}${t.resolved ? ' [resolved]' : ''}`);
|
|
287
|
+
if (t.quote)
|
|
288
|
+
out(` > ${t.quote}`);
|
|
289
|
+
out(` ${t.body}`);
|
|
290
|
+
for (const r of t.replies)
|
|
291
|
+
out(` ↳ ${r.author} ${when(r.created_at)}: ${r.body}`);
|
|
292
|
+
out('');
|
|
293
|
+
}
|
|
294
|
+
return 0;
|
|
295
|
+
}
|
|
296
|
+
case 'rules':
|
|
297
|
+
out(json ? JSON.stringify({ rules: RULES.trim() }) : RULES.trimEnd());
|
|
298
|
+
return 0;
|
|
299
|
+
case 'init': {
|
|
300
|
+
const file = pickAgentsFile(process.cwd());
|
|
301
|
+
const existing = existsSync(file) ? await readFile(file, 'utf8') : '';
|
|
302
|
+
const next = initBlock(existing);
|
|
303
|
+
if (next === existing) {
|
|
304
|
+
out(json ? JSON.stringify({ file, status: 'already present' }) : 'already present');
|
|
305
|
+
return 0;
|
|
306
|
+
}
|
|
307
|
+
await writeFile(file, next);
|
|
308
|
+
out(format({ wrote: file }, json));
|
|
309
|
+
return 0;
|
|
310
|
+
}
|
|
311
|
+
default:
|
|
312
|
+
if (values.help) {
|
|
313
|
+
out(USAGE);
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
err(USAGE);
|
|
317
|
+
return 1;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
catch (e) {
|
|
321
|
+
if (e instanceof CliError) {
|
|
322
|
+
err(e.message);
|
|
323
|
+
return e.code;
|
|
324
|
+
}
|
|
325
|
+
err(e.message);
|
|
326
|
+
return 3;
|
|
327
|
+
}
|
|
328
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "shartifacts",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Publish an HTML page to shartifacts from any agent's shell.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "shartifacts": "dist/bin.js" },
|
|
7
|
+
"files": ["dist"],
|
|
8
|
+
"engines": { "node": ">=20" },
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc -p tsconfig.json",
|
|
11
|
+
"test": "node --test src/*.test.ts",
|
|
12
|
+
"prepublishOnly": "npm run build"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/node": "^22.0.0",
|
|
16
|
+
"typescript": "^5.7.0"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT"
|
|
19
|
+
}
|