@unravel-tech/thing 0.2.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.
Files changed (3) hide show
  1. package/README.md +37 -0
  2. package/package.json +37 -0
  3. package/src/index.js +469 -0
package/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # @unravel-tech/thing
2
+
3
+ CLI for [thing](../../README.md): push, version, and share artifacts — HTML pages plus standalone images and PDFs — from coding agents.
4
+
5
+ ```sh
6
+ npm i -g @unravel-tech/thing
7
+ thing login --server https://your-thing-server.example.com
8
+ thing push report.html --json
9
+ thing push chart.png --json # images and PDFs too
10
+ ```
11
+
12
+ ## Commands
13
+
14
+ | Command | What it does |
15
+ | --- | --- |
16
+ | `thing login [--server url]` | Device-code login; stores a token (does not pin a team) |
17
+ | `thing logout` / `thing whoami` | Clear / show the current identity and where pushes land |
18
+ | `thing default [team] [--clear]` | Show or set your server-side default push target (used when no `--team` is given, from any machine) |
19
+ | `thing use <team> [project]` | Set a local active team/project override for this machine |
20
+ | `thing push <file.html\|.pdf\|.png\|.jpg\|.gif\|.webp> [--name x] [--team t] [--project p] [--visibility v]` | Push a new immutable version (HTML page or image/PDF), print the served URL |
21
+ | `thing list` | List artifacts you can see |
22
+ | `thing versions <name>` | Version history for an artifact |
23
+ | `thing rollback <name> <n>` | Point latest back to version n |
24
+ | `thing open <name>` | Open the artifact in a browser |
25
+
26
+ Every command accepts `--json` for machine-readable output.
27
+
28
+ Visibility values: `private`, `team`, `anyone-with-link` (prints a tokened share URL), `public`.
29
+
30
+ ## Context resolution
31
+
32
+ Which team a push lands in is decided in order: `--team` flag → `.thing.json` in the
33
+ working directory → a local `thing use` override → your **server-side default**
34
+ (`thing default`) → your personal space. Login no longer pins a team, so with none of
35
+ the overrides set the server picks your default (e.g. the Unravel org for Unravel members).
36
+
37
+ Requires Node >= 18 or Bun. No runtime dependencies.
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@unravel-tech/thing",
3
+ "publishConfig": {
4
+ "access": "public"
5
+ },
6
+ "version": "0.2.0",
7
+ "description": "CLI for thing: push, version, and share artifacts (HTML, images, PDFs) from coding agents.",
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/unravel-team/thing.git",
12
+ "directory": "packages/cli"
13
+ },
14
+ "homepage": "https://github.com/unravel-team/thing",
15
+ "type": "module",
16
+ "bin": {
17
+ "thing": "./src/index.js"
18
+ },
19
+ "files": [
20
+ "src/index.js",
21
+ "README.md"
22
+ ],
23
+ "keywords": [
24
+ "artifacts",
25
+ "html",
26
+ "cli",
27
+ "agents",
28
+ "claude-code"
29
+ ],
30
+ "scripts": {
31
+ "smoke": "bun test/cli-smoke.mjs",
32
+ "typecheck": "bun --print \"await import('./src/index.js'); 'ok'\""
33
+ },
34
+ "engines": {
35
+ "node": ">=18"
36
+ }
37
+ }
package/src/index.js ADDED
@@ -0,0 +1,469 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, extname, join, resolve } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { spawn } from "node:child_process";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ const DEFAULT_SERVER = process.env.THING_SERVER || "https://thing.unravel.tech";
9
+ const VISIBILITIES = new Set(["team", "public"]);
10
+
11
+ class CliError extends Error {
12
+ constructor(message, exitCode = 1) {
13
+ super(message);
14
+ this.exitCode = exitCode;
15
+ }
16
+ }
17
+
18
+ function configPath(env = process.env) {
19
+ const root = env.XDG_CONFIG_HOME || join(homedir(), ".config");
20
+ return join(root, "thing", "config.json");
21
+ }
22
+
23
+ function readJson(path) {
24
+ if (!existsSync(path)) return {};
25
+ return JSON.parse(readFileSync(path, "utf8"));
26
+ }
27
+
28
+ function writeJson(path, value) {
29
+ mkdirSync(dirname(path), { recursive: true });
30
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
31
+ }
32
+
33
+ function projectConfigPath(cwd) {
34
+ return join(cwd, ".thing.json");
35
+ }
36
+
37
+ function loadState(cwd, env = process.env) {
38
+ return {
39
+ globalPath: configPath(env),
40
+ global: readJson(configPath(env)),
41
+ projectPath: projectConfigPath(cwd),
42
+ project: readJson(projectConfigPath(cwd))
43
+ };
44
+ }
45
+
46
+ function saveGlobal(state) {
47
+ writeJson(state.globalPath, state.global);
48
+ }
49
+
50
+ function parseArgv(argv) {
51
+ const args = [...argv];
52
+ const command = args.shift();
53
+ const positionals = [];
54
+ const flags = {};
55
+ let json = false;
56
+
57
+ for (let i = 0; i < args.length; i += 1) {
58
+ const arg = args[i];
59
+ if (arg === "--json") {
60
+ json = true;
61
+ continue;
62
+ }
63
+ if (!arg.startsWith("--")) {
64
+ positionals.push(arg);
65
+ continue;
66
+ }
67
+ const eq = arg.indexOf("=");
68
+ if (eq !== -1) {
69
+ flags[arg.slice(2, eq)] = arg.slice(eq + 1);
70
+ continue;
71
+ }
72
+ const key = arg.slice(2);
73
+ const next = args[i + 1];
74
+ if (!next || next.startsWith("--")) {
75
+ flags[key] = true;
76
+ } else {
77
+ flags[key] = next;
78
+ i += 1;
79
+ }
80
+ }
81
+
82
+ return { command, positionals, flags, json };
83
+ }
84
+
85
+ function stripSlash(server) {
86
+ return String(server || DEFAULT_SERVER).replace(/\/+$/, "");
87
+ }
88
+
89
+ function context(state, flags = {}) {
90
+ return {
91
+ server: stripSlash(flags.server || state.project.server || state.global.server || DEFAULT_SERVER),
92
+ token: flags.token || state.global.token || null,
93
+ team: flags.team || state.project.team || state.global.activeTeam || null,
94
+ project: flags.project || state.project.project || state.global.activeProject || null
95
+ };
96
+ }
97
+
98
+ function requireToken(ctx) {
99
+ if (!ctx.token) throw new CliError("Not logged in. Run `thing login` first.");
100
+ }
101
+
102
+ function slugify(input) {
103
+ return String(input || "artifact")
104
+ .toLowerCase()
105
+ .replace(/\.html?$/i, "")
106
+ .replace(/[^a-z0-9]+/g, "-")
107
+ .replace(/^-+|-+$/g, "") || "artifact";
108
+ }
109
+
110
+ function titleFromFile(path) {
111
+ const base = path.split(/[\\/]/).pop() || "artifact";
112
+ return base.replace(/\.[^.]+$/, "") || "artifact";
113
+ }
114
+
115
+ // Standalone media types thing accepts as their own artifact. Mirrors the
116
+ // server allowlist so we fail fast with a friendly message before uploading.
117
+ const MEDIA_EXTS = new Set([".pdf", ".png", ".jpg", ".jpeg", ".gif", ".webp"]);
118
+
119
+ function output(io, json, value, text) {
120
+ if (json) io.stdout.write(`${JSON.stringify(value)}\n`);
121
+ else io.stdout.write(`${text ?? formatText(value)}\n`);
122
+ }
123
+
124
+ function formatText(value) {
125
+ if (typeof value === "string") return value;
126
+ return JSON.stringify(value, null, 2);
127
+ }
128
+
129
+ async function api(ctx, path, options = {}) {
130
+ const headers = {
131
+ Accept: "application/json",
132
+ ...(options.body ? { "Content-Type": "application/json" } : {}),
133
+ ...(ctx.token ? { Authorization: `Bearer ${ctx.token}` } : {}),
134
+ ...(options.headers || {})
135
+ };
136
+ const response = await fetch(`${ctx.server}${path}`, {
137
+ method: options.method || "GET",
138
+ headers,
139
+ body: options.body ? JSON.stringify(options.body) : undefined
140
+ });
141
+ const text = await response.text();
142
+ let data = null;
143
+ if (text) {
144
+ try {
145
+ data = JSON.parse(text);
146
+ } catch {
147
+ data = { text };
148
+ }
149
+ }
150
+ if (!response.ok) {
151
+ const message = data?.error || data?.text || `${response.status} ${response.statusText}`;
152
+ const error = new CliError(message, response.status === 401 ? 2 : 1);
153
+ error.response = data;
154
+ throw error;
155
+ }
156
+ return data ?? {};
157
+ }
158
+
159
+ async function sleep(ms) {
160
+ await new Promise((resolve) => setTimeout(resolve, ms));
161
+ }
162
+
163
+ async function login(parsed, state, io) {
164
+ const server = stripSlash(parsed.flags.server || state.global.server || DEFAULT_SERVER);
165
+ const device = await api({ server }, "/api/v1/auth/device/code", { method: "POST", body: {} });
166
+ const loginMessage = `Open ${device.verification_uri_complete || device.verification_uri}\nEnter code: ${device.user_code}`;
167
+ output(io, parsed.json, {
168
+ server,
169
+ verificationUri: device.verification_uri,
170
+ verificationUriComplete: device.verification_uri_complete,
171
+ userCode: device.user_code,
172
+ expiresIn: device.expires_in,
173
+ interval: device.interval
174
+ }, loginMessage);
175
+
176
+ const deadline = Date.now() + (Number(device.expires_in || 900) * 1000);
177
+ const interval = Math.max(250, Number(process.env.THING_POLL_INTERVAL_MS || Math.max(1, Number(device.interval || 5)) * 1000));
178
+ let tokenResult = null;
179
+ while (Date.now() < deadline) {
180
+ await sleep(interval);
181
+ try {
182
+ tokenResult = await api({ server }, "/api/v1/auth/device/token", {
183
+ method: "POST",
184
+ body: {
185
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
186
+ device_code: device.device_code
187
+ }
188
+ });
189
+ break;
190
+ } catch (error) {
191
+ if (error.response?.error === "authorization_pending") continue;
192
+ throw error;
193
+ }
194
+ }
195
+ if (!tokenResult?.access_token) throw new CliError("Login timed out before approval.");
196
+
197
+ const ctx = { server, token: tokenResult.access_token };
198
+ const whoami = await api(ctx, "/api/v1/whoami");
199
+
200
+ state.global.server = server;
201
+ state.global.token = tokenResult.access_token;
202
+ // Login no longer pins an "active team". With none set the CLI sends no team
203
+ // and the server routes the push to the caller's default (e.g. the Unravel
204
+ // org for Unravel members). Clear any team an older CLI pinned so existing
205
+ // installs self-heal; `thing use` stays the explicit opt-in override.
206
+ delete state.global.activeTeam;
207
+ if (!state.global.activeProject) delete state.global.activeProject;
208
+ saveGlobal(state);
209
+
210
+ const defaultTeam = whoami.defaultTeam?.slug || null;
211
+ output(io, parsed.json, {
212
+ ok: true,
213
+ server,
214
+ user: whoami.user,
215
+ defaultTeam
216
+ }, `Logged in as ${whoami.user.email}${defaultTeam ? ` (pushes default to ${defaultTeam})` : ""}`);
217
+ }
218
+
219
+ async function logout(parsed, state, io) {
220
+ delete state.global.token;
221
+ delete state.global.activeTeam;
222
+ delete state.global.activeProject;
223
+ saveGlobal(state);
224
+ output(io, parsed.json, { ok: true }, "Logged out");
225
+ }
226
+
227
+ async function whoami(parsed, state, io) {
228
+ const ctx = context(state, parsed.flags);
229
+ requireToken(ctx);
230
+ const data = await api(ctx, "/api/v1/whoami");
231
+ // Where a no-flag push lands: a local override (`thing use`/.thing.json) wins;
232
+ // otherwise the server default; otherwise the personal space.
233
+ const serverDefault = data.defaultTeam?.slug || null;
234
+ const target = ctx.team ? `${ctx.team} (local override)` : serverDefault ? `${serverDefault} (server default)` : "your personal space";
235
+ output(
236
+ io,
237
+ parsed.json,
238
+ { ...data, server: ctx.server, activeTeam: ctx.team, activeProject: ctx.project },
239
+ `${data.user.email}\nserver: ${ctx.server}\npushes go to: ${target}${ctx.project ? `\nproject: ${ctx.project}` : ""}`
240
+ );
241
+ }
242
+
243
+ async function useContext(parsed, state, io) {
244
+ const [team, project] = parsed.positionals;
245
+ if (!team) throw new CliError("Usage: thing use <team> [project]");
246
+ state.global.activeTeam = team;
247
+ if (project) state.global.activeProject = project;
248
+ else delete state.global.activeProject;
249
+ saveGlobal(state);
250
+ output(io, parsed.json, { team, project: project || null }, `Active context set to ${team}${project ? `/${project}` : ""}`);
251
+ }
252
+
253
+ // Sets the server-side default team: the one pushes go to when no --team is
254
+ // given, from any client. Distinct from `use`, which is a local-only context.
255
+ async function defaultTeam(parsed, state, io) {
256
+ const ctx = context(state, parsed.flags);
257
+ requireToken(ctx);
258
+ const [team] = parsed.positionals;
259
+ const clear = Boolean(parsed.flags.clear);
260
+
261
+ if (!team && !clear) {
262
+ const me = await api(ctx, "/api/v1/whoami");
263
+ const current = me.defaultTeam?.slug || null;
264
+ output(io, parsed.json, { defaultTeam: me.defaultTeam ?? null }, current ? `Default push target: ${current}` : "No default push target set");
265
+ return;
266
+ }
267
+
268
+ const data = await api(ctx, "/api/v1/me/default-team", { method: "PUT", body: { slug: clear ? null : team } });
269
+ const slug = data.defaultTeam?.slug || null;
270
+ output(io, parsed.json, data, slug ? `Default push target set to ${slug}` : "Default push target cleared");
271
+ }
272
+
273
+ async function list(parsed, state, io) {
274
+ const ctx = context(state, parsed.flags);
275
+ requireToken(ctx);
276
+ const data = await api(ctx, "/api/v1/artifacts");
277
+ let artifacts = data.artifacts || [];
278
+ if (ctx.team) artifacts = artifacts.filter((artifact) => artifact.teamSlug === ctx.team);
279
+ if (ctx.project) artifacts = artifacts.filter((artifact) => artifact.projectSlug === ctx.project);
280
+ output(io, parsed.json, { artifacts }, artifacts.length ? artifacts.map((a) => `${a.teamSlug}/${a.slug}\t${a.visibility}\t${a.title}`).join("\n") : "No artifacts");
281
+ }
282
+
283
+ async function resolveArtifact(ctx, name) {
284
+ const slug = slugify(name);
285
+ const data = await api(ctx, "/api/v1/artifacts");
286
+ const artifacts = data.artifacts || [];
287
+ const matches = artifacts.filter((artifact) => {
288
+ if (ctx.team && artifact.teamSlug !== ctx.team) return false;
289
+ if (ctx.project && artifact.projectSlug !== ctx.project) return false;
290
+ return artifact.slug === slug || artifact.slug === name || artifact.title === name;
291
+ });
292
+ if (matches.length === 0) throw new CliError(`Artifact not found: ${name}`);
293
+ if (matches.length > 1) throw new CliError(`Artifact name is ambiguous: ${name}. Use --team.`);
294
+ return matches[0];
295
+ }
296
+
297
+ async function detail(ctx, artifact) {
298
+ return api(ctx, `/api/v1/artifacts/${encodeURIComponent(artifact.id)}`);
299
+ }
300
+
301
+ async function push(parsed, state, io) {
302
+ const [file] = parsed.positionals;
303
+ if (!file) throw new CliError("Usage: thing push <file.html|.pdf|.png|.jpg|.gif|.webp> [--name x] [--team t] [--project p] [--visibility team|public]");
304
+ const ctx = context(state, parsed.flags);
305
+ requireToken(ctx);
306
+ const path = resolve(file);
307
+ if (!existsSync(path)) throw new CliError(`File not found: ${file}`);
308
+
309
+ const ext = extname(path).toLowerCase();
310
+ const isHtml = ext === ".html" || ext === ".htm";
311
+ if (!isHtml && !MEDIA_EXTS.has(ext)) {
312
+ throw new CliError(`Unsupported file type: ${ext || file}. Push an HTML page or a pdf/png/jpg/gif/webp file.`);
313
+ }
314
+ const name = parsed.flags.name || titleFromFile(path);
315
+ const visibility = parsed.flags.visibility;
316
+ if (visibility && !VISIBILITIES.has(visibility)) throw new CliError("Invalid visibility. Use team or public.");
317
+
318
+ // HTML rides as text; media rides as base64 bytes with its real filename so
319
+ // the server can derive the content-type from the extension.
320
+ const doc = isHtml
321
+ ? { filename: "index.html", html: readFileSync(path, "utf8") }
322
+ : { filename: basename(path), contentBase64: readFileSync(path).toString("base64") };
323
+
324
+ const pushed = await api(ctx, "/api/v1/artifacts", {
325
+ method: "POST",
326
+ body: {
327
+ team: ctx.team || undefined,
328
+ project: ctx.project || undefined,
329
+ slug: slugify(name),
330
+ title: name,
331
+ visibility: visibility || undefined,
332
+ ...doc
333
+ }
334
+ });
335
+ const result = {
336
+ artifact: pushed.artifact,
337
+ version: pushed.version,
338
+ url: pushed.artifact.url,
339
+ visibility: pushed.artifact.visibility,
340
+ tokenedUrl: null
341
+ };
342
+ output(io, parsed.json, result, pushed.artifact.url);
343
+ }
344
+
345
+ async function versionsCommand(parsed, state, io) {
346
+ const [name] = parsed.positionals;
347
+ if (!name) throw new CliError("Usage: thing versions <name>");
348
+ const ctx = context(state, parsed.flags);
349
+ requireToken(ctx);
350
+ const artifact = await resolveArtifact(ctx, name);
351
+ const full = await detail(ctx, artifact);
352
+ output(io, parsed.json, full, full.versions.length ? full.versions.map((v) => `v${v.number}\t${v.id === full.artifact.latestVersionId ? "latest" : ""}\t${v.createdAt}`).join("\n") : "No versions");
353
+ }
354
+
355
+ async function rollback(parsed, state, io) {
356
+ const [name, versionText] = parsed.positionals;
357
+ if (!name || !versionText) throw new CliError("Usage: thing rollback <name> <version>");
358
+ const version = Number(versionText);
359
+ if (!Number.isInteger(version) || version <= 0) throw new CliError("Version must be a positive integer.");
360
+ const ctx = context(state, parsed.flags);
361
+ requireToken(ctx);
362
+ const artifact = await resolveArtifact(ctx, name);
363
+ const result = await api(ctx, `/api/v1/artifacts/${encodeURIComponent(artifact.id)}/rollback`, {
364
+ method: "POST",
365
+ body: { version }
366
+ });
367
+ output(io, parsed.json, { artifact, ...result }, `Rolled back ${artifact.teamSlug}/${artifact.slug} to v${result.latestVersion}`);
368
+ }
369
+
370
+ function openerCommand(url) {
371
+ if (process.platform === "darwin") return ["open", [url]];
372
+ if (process.platform === "win32") return ["cmd", ["/c", "start", "", url]];
373
+ return ["xdg-open", [url]];
374
+ }
375
+
376
+ async function openCommand(parsed, state, io) {
377
+ const [name] = parsed.positionals;
378
+ if (!name) throw new CliError("Usage: thing open <name>");
379
+ const ctx = context(state, parsed.flags);
380
+ requireToken(ctx);
381
+ const artifact = await resolveArtifact(ctx, name);
382
+ const url = `${ctx.server}/${artifact.teamSlug}/${artifact.slug}`;
383
+ if (!parsed.json && !parsed.flags["no-browser"]) {
384
+ const [cmd, args] = openerCommand(url);
385
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
386
+ child.unref();
387
+ }
388
+ output(io, parsed.json, { url, artifact }, url);
389
+ }
390
+
391
+ function usage() {
392
+ return `Usage: thing <command> [options]
393
+
394
+ Commands:
395
+ login [--server url]
396
+ logout
397
+ whoami
398
+ use <team> [project]
399
+ default [team] [--clear]
400
+ push <file.html|.pdf|.png|.jpg|.gif|.webp> [--name x] [--team t] [--project p] [--visibility team|public]
401
+ list
402
+ versions <name>
403
+ rollback <name> <version>
404
+ open <name>
405
+
406
+ Global options:
407
+ --json
408
+ `;
409
+ }
410
+
411
+ export async function run(argv = process.argv.slice(2), io = { stdout: process.stdout, stderr: process.stderr, cwd: process.cwd(), env: process.env }) {
412
+ const parsed = parseArgv(argv);
413
+ const state = loadState(io.cwd || process.cwd(), io.env || process.env);
414
+ try {
415
+ switch (parsed.command) {
416
+ case "login":
417
+ await login(parsed, state, io);
418
+ break;
419
+ case "logout":
420
+ await logout(parsed, state, io);
421
+ break;
422
+ case "whoami":
423
+ await whoami(parsed, state, io);
424
+ break;
425
+ case "use":
426
+ await useContext(parsed, state, io);
427
+ break;
428
+ case "default":
429
+ await defaultTeam(parsed, state, io);
430
+ break;
431
+ case "push":
432
+ await push(parsed, state, io);
433
+ break;
434
+ case "list":
435
+ await list(parsed, state, io);
436
+ break;
437
+ case "versions":
438
+ await versionsCommand(parsed, state, io);
439
+ break;
440
+ case "rollback":
441
+ await rollback(parsed, state, io);
442
+ break;
443
+ case "open":
444
+ await openCommand(parsed, state, io);
445
+ break;
446
+ case "-h":
447
+ case "--help":
448
+ case undefined:
449
+ io.stdout.write(usage());
450
+ break;
451
+ default:
452
+ throw new CliError(`Unknown command: ${parsed.command}\n${usage()}`);
453
+ }
454
+ return 0;
455
+ } catch (error) {
456
+ if (parsed.json) {
457
+ io.stdout.write(`${JSON.stringify({ error: error.message })}\n`);
458
+ } else {
459
+ io.stderr.write(`${error.message}\n`);
460
+ }
461
+ return error.exitCode || 1;
462
+ }
463
+ }
464
+
465
+ const invokedPath = process.argv[1] ? realpathSync(process.argv[1]) : "";
466
+ if (import.meta.url === pathToFileURL(invokedPath).href) {
467
+ const code = await run();
468
+ process.exitCode = code;
469
+ }