@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.
- package/README.md +37 -0
- package/package.json +37 -0
- 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
|
+
}
|