@dcl-regenesislabs/artifacts 1.0.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 +357 -0
- package/cli/artifacts.mjs +706 -0
- package/package.json +48 -0
- package/shared/artifact-id.js +175 -0
- package/templates/artifact/assets/dcl.css +279 -0
- package/templates/artifact/assets/dcl.js +62 -0
- package/templates/artifact/index.html +161 -0
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dcl-artifacts — publish a folder (or a single file) to the artifact host.
|
|
4
|
+
*
|
|
5
|
+
* node cli/artifacts.mjs push ./dist --name "my demo" # new artifact
|
|
6
|
+
* node cli/artifacts.mjs push ./dist --id <id or URL> # new version of it
|
|
7
|
+
* node cli/artifacts.mjs info <id or URL>
|
|
8
|
+
* node cli/artifacts.mjs ls
|
|
9
|
+
* node cli/artifacts.mjs rm <id or URL>
|
|
10
|
+
*
|
|
11
|
+
* Zero dependencies: Node's built-in fetch, node:crypto and node:fs only.
|
|
12
|
+
*/
|
|
13
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
14
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
15
|
+
import { realpathSync } from "node:fs";
|
|
16
|
+
import { mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
17
|
+
import { createServer } from "node:http";
|
|
18
|
+
import { homedir, hostname, userInfo } from "node:os";
|
|
19
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
MAX_PATH_LEN,
|
|
24
|
+
newId,
|
|
25
|
+
parseId,
|
|
26
|
+
randomB64url,
|
|
27
|
+
sha256B64url,
|
|
28
|
+
userCode,
|
|
29
|
+
validateManifest,
|
|
30
|
+
} from "../shared/artifact-id.js";
|
|
31
|
+
|
|
32
|
+
const DEFAULT_BASE = "https://artifacts.dclregenesislabs.xyz";
|
|
33
|
+
const TEMPLATE_DIR = fileURLToPath(new URL("../templates/artifact/", import.meta.url));
|
|
34
|
+
const TEMPLATE_TITLE = "Artifact template";
|
|
35
|
+
const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|
36
|
+
const UPLOAD_CONCURRENCY = 6;
|
|
37
|
+
const SKIP = new Set([".git", "node_modules", ".wrangler", ".DS_Store", "Thumbs.db", ".env", ".dev.vars", "credentials.json"]);
|
|
38
|
+
|
|
39
|
+
const MIME = {
|
|
40
|
+
html: "text/html; charset=utf-8", htm: "text/html; charset=utf-8",
|
|
41
|
+
js: "text/javascript; charset=utf-8", mjs: "text/javascript; charset=utf-8",
|
|
42
|
+
css: "text/css; charset=utf-8", json: "application/json; charset=utf-8",
|
|
43
|
+
txt: "text/plain; charset=utf-8", md: "text/markdown; charset=utf-8",
|
|
44
|
+
csv: "text/csv; charset=utf-8", xml: "application/xml; charset=utf-8",
|
|
45
|
+
svg: "image/svg+xml", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
|
|
46
|
+
gif: "image/gif", webp: "image/webp", avif: "image/avif", ico: "image/x-icon",
|
|
47
|
+
mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", mp4: "video/mp4", webm: "video/webm",
|
|
48
|
+
woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", otf: "font/otf",
|
|
49
|
+
pdf: "application/pdf", wasm: "application/wasm", zip: "application/zip",
|
|
50
|
+
glb: "model/gltf-binary", gltf: "model/gltf+json",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const USAGE = `dcl-artifacts — publish static artifacts to ${DEFAULT_BASE}
|
|
54
|
+
|
|
55
|
+
Usage
|
|
56
|
+
dcl-artifacts login Sign in with your work email, in a browser
|
|
57
|
+
dcl-artifacts logout [--all] Revoke this machine's token (--all: every machine)
|
|
58
|
+
dcl-artifacts whoami Who the current token belongs to
|
|
59
|
+
dcl-artifacts new <dir> [--name <title>] Scaffold a single-file page from the house template
|
|
60
|
+
dcl-artifacts push <dir|file> [...] [options] Publish a new artifact, or a new version with --id
|
|
61
|
+
dcl-artifacts info <id|url> Name, versions, visibility
|
|
62
|
+
dcl-artifacts ls Every artifact on the host, newest first
|
|
63
|
+
dcl-artifacts rm <id|url> Delete it, every version, and its public link
|
|
64
|
+
|
|
65
|
+
Options
|
|
66
|
+
--id <id|url> Push as a new version of this artifact instead of creating one.
|
|
67
|
+
The artifact's URL works as well as the bare id.
|
|
68
|
+
--name <name> Label shown in the bar. Default: the folder/file name for a
|
|
69
|
+
new artifact; unchanged for a new version. For 'new' it is
|
|
70
|
+
also the page title.
|
|
71
|
+
--base <url> Host to talk to (default: $DCL_ARTIFACTS_BASE or ${DEFAULT_BASE})
|
|
72
|
+
--token <tok> Upload token. Normally unnecessary: login stores one.
|
|
73
|
+
Precedence is --token, then $DCL_ARTIFACTS_TOKEN, then the
|
|
74
|
+
saved credentials.
|
|
75
|
+
--all push: include normally-skipped entries (.git, node_modules, ...)
|
|
76
|
+
logout: revoke every token issued to your email, not just this one
|
|
77
|
+
--json Emit machine-readable JSON on stdout
|
|
78
|
+
-h, --help Show this help
|
|
79
|
+
|
|
80
|
+
URLs
|
|
81
|
+
${DEFAULT_BASE}/<id> latest version — needs a work-email login
|
|
82
|
+
${DEFAULT_BASE}/<id>@2 one specific version, pinned
|
|
83
|
+
${DEFAULT_BASE}/p/<alias> public link, no login — switched on and off from
|
|
84
|
+
the bar at the top of the page, by anyone
|
|
85
|
+
who can open it. Not from here.
|
|
86
|
+
|
|
87
|
+
Signing in
|
|
88
|
+
'login' opens a browser, you sign in with a @dclregenesislabs.xyz or
|
|
89
|
+
@decentraland.org email, and the token it gives back is written to
|
|
90
|
+
~/.config/dcl-artifacts/credentials.json (mode 600). Tokens last 90 days;
|
|
91
|
+
run 'login' again to renew, 'logout' to revoke. Nothing needs to be pasted
|
|
92
|
+
anywhere, and no token is ever printed.
|
|
93
|
+
|
|
94
|
+
An artifact keeps its id for life; each push with --id adds a version. Pushing
|
|
95
|
+
bytes identical to the latest version records nothing. Blobs are stored by
|
|
96
|
+
digest, so a new version uploads only what changed.`;
|
|
97
|
+
|
|
98
|
+
function parseArgs(argv) {
|
|
99
|
+
const opts = { positional: [], name: null, id: null, base: null, token: null, all: false, json: false };
|
|
100
|
+
for (let i = 0; i < argv.length; i++) {
|
|
101
|
+
const arg = argv[i];
|
|
102
|
+
switch (arg) {
|
|
103
|
+
case "--name": opts.name = argv[++i]; break;
|
|
104
|
+
case "--id": opts.id = argv[++i]; break;
|
|
105
|
+
case "--base": opts.base = argv[++i]; break;
|
|
106
|
+
case "--token": opts.token = argv[++i]; break;
|
|
107
|
+
case "--all": opts.all = true; break;
|
|
108
|
+
case "--json": opts.json = true; break;
|
|
109
|
+
case "-h": case "--help": opts.help = true; break;
|
|
110
|
+
default:
|
|
111
|
+
if (arg.startsWith("--")) throw new Error(`unknown option: ${arg}`);
|
|
112
|
+
opts.positional.push(arg);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return opts;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Accepts an id or any URL on the host; fails loudly on anything else. */
|
|
119
|
+
function requireId(input, what) {
|
|
120
|
+
if (!input) throw new Error(`${what} needs an artifact id or URL`);
|
|
121
|
+
const id = parseId(input);
|
|
122
|
+
if (!id) throw new Error(`not an artifact id or URL: ${input}`);
|
|
123
|
+
return id;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function contentTypeFor(path) {
|
|
127
|
+
const ext = path.includes(".") ? path.slice(path.lastIndexOf(".") + 1).toLowerCase() : "";
|
|
128
|
+
return MIME[ext] ?? "application/octet-stream";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
|
|
132
|
+
|
|
133
|
+
/** Best effort "who pushed this" — informational, the token is what authorizes. */
|
|
134
|
+
function author() {
|
|
135
|
+
if (process.env.DCL_ARTIFACTS_AUTHOR) return process.env.DCL_ARTIFACTS_AUTHOR;
|
|
136
|
+
try {
|
|
137
|
+
const email = execFileSync("git", ["config", "user.email"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
|
|
138
|
+
if (email) return email;
|
|
139
|
+
} catch { /* no git, or no email configured */ }
|
|
140
|
+
try { return userInfo().username; } catch { return null; }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function walk(root, includeAll) {
|
|
144
|
+
const out = [];
|
|
145
|
+
async function visit(dir) {
|
|
146
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
147
|
+
if (!includeAll && SKIP.has(entry.name)) continue;
|
|
148
|
+
const abs = join(dir, entry.name);
|
|
149
|
+
if (entry.isDirectory()) {
|
|
150
|
+
await visit(abs);
|
|
151
|
+
} else if (entry.isFile()) {
|
|
152
|
+
out.push({ abs, path: relative(root, abs).split(sep).join("/") });
|
|
153
|
+
}
|
|
154
|
+
// symlinks are skipped: they can escape the root or loop
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
await visit(root);
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function buildFileList(target, opts) {
|
|
162
|
+
const abs = resolve(target);
|
|
163
|
+
const info = await stat(abs).catch(() => null);
|
|
164
|
+
if (!info) throw new Error(`no such file or directory: ${target}`);
|
|
165
|
+
|
|
166
|
+
const entries = info.isDirectory()
|
|
167
|
+
? await walk(abs, opts.all)
|
|
168
|
+
: [{ abs, path: basename(abs) }];
|
|
169
|
+
if (entries.length === 0) throw new Error(`${target} contains no files`);
|
|
170
|
+
|
|
171
|
+
const files = [];
|
|
172
|
+
for (const entry of entries) {
|
|
173
|
+
const body = await readFile(entry.abs);
|
|
174
|
+
if (body.byteLength > MAX_FILE_BYTES) {
|
|
175
|
+
throw new Error(`${entry.path} is ${(body.byteLength / 1e6).toFixed(1)} MB — the limit is 25 MB`);
|
|
176
|
+
}
|
|
177
|
+
if (entry.path.length > MAX_PATH_LEN) throw new Error(`path too long: ${entry.path}`);
|
|
178
|
+
files.push({
|
|
179
|
+
path: entry.path,
|
|
180
|
+
size: body.byteLength,
|
|
181
|
+
sha256: sha256(body),
|
|
182
|
+
contentType: contentTypeFor(entry.path),
|
|
183
|
+
_abs: entry.abs,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const list = { files: files.map(({ _abs, ...f }) => f) };
|
|
188
|
+
const problem = validateManifest(list);
|
|
189
|
+
if (problem) throw new Error(problem);
|
|
190
|
+
const bySha = new Map();
|
|
191
|
+
for (const f of files) if (!bySha.has(f.sha256)) bySha.set(f.sha256, f);
|
|
192
|
+
return { files: list.files, bySha, defaultName: basename(abs) };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
class Client {
|
|
196
|
+
constructor(base, token) {
|
|
197
|
+
this.base = base.replace(/\/+$/, "");
|
|
198
|
+
this.token = token || null;
|
|
199
|
+
}
|
|
200
|
+
async call(method, path, { body, headers = {} } = {}) {
|
|
201
|
+
const res = await fetch(`${this.base}${path}`, {
|
|
202
|
+
method,
|
|
203
|
+
headers: {
|
|
204
|
+
...(this.token ? { authorization: `Bearer ${this.token}` } : {}),
|
|
205
|
+
...headers,
|
|
206
|
+
},
|
|
207
|
+
body,
|
|
208
|
+
});
|
|
209
|
+
const text = await res.text();
|
|
210
|
+
let parsed = null;
|
|
211
|
+
try { parsed = text ? JSON.parse(text) : null; } catch { /* non-JSON error page */ }
|
|
212
|
+
if (!res.ok) {
|
|
213
|
+
const detail = parsed?.error ?? text.slice(0, 300) ?? res.statusText;
|
|
214
|
+
const hint = res.status === 401 ? " — run: dcl-artifacts login" : "";
|
|
215
|
+
const err = new Error(`${method} ${path} → ${res.status}: ${detail}${hint}`);
|
|
216
|
+
err.status = res.status;
|
|
217
|
+
err.body = parsed;
|
|
218
|
+
throw err;
|
|
219
|
+
}
|
|
220
|
+
return parsed;
|
|
221
|
+
}
|
|
222
|
+
json(method, path, body) {
|
|
223
|
+
return this.call(method, path, { body: JSON.stringify(body), headers: { "content-type": "application/json" } });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The template is kept as index.html + assets/ so the CSS and JS stay editable
|
|
229
|
+
* as plain files, but what we hand out is ONE self-contained index.html with
|
|
230
|
+
* both inlined — the same shape as a Claude artifact, so it works anywhere.
|
|
231
|
+
*/
|
|
232
|
+
async function renderTemplate(name) {
|
|
233
|
+
const read = (...parts) => readFile(join(TEMPLATE_DIR, ...parts), "utf8");
|
|
234
|
+
const [html, css, js] = await Promise.all([read("index.html"), read("assets", "dcl.css"), read("assets", "dcl.js")]);
|
|
235
|
+
const markers = [
|
|
236
|
+
['<link rel="stylesheet" href="./assets/dcl.css">', `<style>\n${css.trim()}\n</style>`],
|
|
237
|
+
['<script src="./assets/dcl.js"></script>', `<script>\n${js.trim()}\n</script>`],
|
|
238
|
+
];
|
|
239
|
+
let out = html;
|
|
240
|
+
for (const [marker, replacement] of markers) {
|
|
241
|
+
if (!out.includes(marker)) throw new Error(`template is missing ${marker}`);
|
|
242
|
+
out = out.replace(marker, () => replacement); // function form: no $-pattern expansion
|
|
243
|
+
}
|
|
244
|
+
return name ? out.replaceAll(TEMPLATE_TITLE, name) : out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Write a fresh single-file page from the house template. */
|
|
248
|
+
async function scaffold(target, name, log) {
|
|
249
|
+
const dest = resolve(target);
|
|
250
|
+
const existing = await readdir(dest).catch((err) => (err.code === "ENOENT" ? [] : Promise.reject(err)));
|
|
251
|
+
if (existing.length > 0) throw new Error(`${target} is not empty — scaffold into a fresh directory`);
|
|
252
|
+
await mkdir(dest, { recursive: true });
|
|
253
|
+
await writeFile(join(dest, "index.html"), await renderTemplate(name));
|
|
254
|
+
const shown = relative(process.cwd(), dest);
|
|
255
|
+
log(`scaffolded ${!shown || shown.startsWith("..") ? dest : shown}/index.html — one self-contained file`);
|
|
256
|
+
return dest;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Run `worker` over `items` with a bounded number of in-flight promises. */
|
|
260
|
+
async function pooled(items, limit, worker) {
|
|
261
|
+
const queue = [...items];
|
|
262
|
+
const runners = Array.from({ length: Math.min(limit, queue.length) }, async () => {
|
|
263
|
+
while (queue.length > 0) await worker(queue.shift());
|
|
264
|
+
});
|
|
265
|
+
await Promise.all(runners);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function push(target, client, opts, log) {
|
|
269
|
+
const { files, bySha, defaultName } = await buildFileList(target, opts);
|
|
270
|
+
const creating = opts.id === null;
|
|
271
|
+
const id = creating ? newId() : requireId(opts.id, "--id");
|
|
272
|
+
const name = opts.name ?? (creating ? defaultName : undefined);
|
|
273
|
+
const total = files.reduce((sum, f) => sum + f.size, 0);
|
|
274
|
+
log(`${name ?? id}: ${files.length} file(s), ${(total / 1024).toFixed(1)} KiB`);
|
|
275
|
+
|
|
276
|
+
const state = await client.json("POST", `/_api/artifacts/${id}/status`, { files });
|
|
277
|
+
if (!creating && !state.exists) {
|
|
278
|
+
throw new Error(`no artifact ${id} on ${client.base} — drop --id to create a new one`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (state.missing.length === 0) {
|
|
282
|
+
log(` every blob is already on the host`);
|
|
283
|
+
} else {
|
|
284
|
+
log(` uploading ${state.missing.length} blob(s) (${state.present} already there)`);
|
|
285
|
+
let done = 0;
|
|
286
|
+
await pooled(state.missing, UPLOAD_CONCURRENCY, async (sha) => {
|
|
287
|
+
const file = bySha.get(sha);
|
|
288
|
+
const body = await readFile(file._abs);
|
|
289
|
+
await client.call("PUT", `/_api/artifacts/${id}/blobs/${sha}`, {
|
|
290
|
+
body,
|
|
291
|
+
headers: {
|
|
292
|
+
"x-content-type": file.contentType,
|
|
293
|
+
"content-type": "application/octet-stream",
|
|
294
|
+
"content-length": String(body.byteLength),
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
log(` [${++done}/${state.missing.length}] ${file.path}`);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const result = await client.json("POST", `/_api/artifacts/${id}/commit`, {
|
|
302
|
+
files, base: state.current, name, author: author(),
|
|
303
|
+
});
|
|
304
|
+
if (!result.hasIndex) {
|
|
305
|
+
log(` warning: no index.html — the artifact root will 404, link a specific file instead`);
|
|
306
|
+
}
|
|
307
|
+
log(result.unchanged
|
|
308
|
+
? ` identical to v${result.version} — nothing recorded`
|
|
309
|
+
: ` v${result.version} of "${result.name}"${creating ? " (new artifact)" : ""}`);
|
|
310
|
+
return result;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function fmtWhen(iso) {
|
|
314
|
+
return new Date(iso).toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------- credentials
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Where the upload token lives once you have signed in.
|
|
321
|
+
*
|
|
322
|
+
* Deliberately NOT under ~/.dcl-artifacts: the installer treats that directory
|
|
323
|
+
* as replaceable downloaded code and `rm -rf`s it on every update, which would
|
|
324
|
+
* take the credential with it. And deliberately not inside SKILL.md, where it
|
|
325
|
+
* used to sit — that file is read into a model's prompt every time an artifact
|
|
326
|
+
* is published.
|
|
327
|
+
*/
|
|
328
|
+
function credentialsPath() {
|
|
329
|
+
if (process.env.DCL_ARTIFACTS_CREDENTIALS) return resolve(process.env.DCL_ARTIFACTS_CREDENTIALS);
|
|
330
|
+
const base =
|
|
331
|
+
process.platform === "win32"
|
|
332
|
+
? (process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"))
|
|
333
|
+
: (process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"));
|
|
334
|
+
return join(base, "dcl-artifacts", "credentials.json");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Keyed by host so a dev server and production never share a token. */
|
|
338
|
+
const hostKey = (base) => base.replace(/\/+$/, "");
|
|
339
|
+
|
|
340
|
+
async function readCredentialsFile(log) {
|
|
341
|
+
const path = credentialsPath();
|
|
342
|
+
let raw;
|
|
343
|
+
try {
|
|
344
|
+
raw = await readFile(path, "utf8");
|
|
345
|
+
} catch {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
if (process.platform !== "win32") {
|
|
349
|
+
try {
|
|
350
|
+
const { mode } = await stat(path);
|
|
351
|
+
// Same complaint ssh makes, for the same reason.
|
|
352
|
+
if (mode & 0o077) log?.(`warning: ${path} is readable by other users — chmod 600 it`);
|
|
353
|
+
} catch { /* if it cannot be stat'd, the read below will fail anyway */ }
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
return JSON.parse(raw);
|
|
357
|
+
} catch {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function readCredentials(base, log) {
|
|
363
|
+
const file = await readCredentialsFile(log);
|
|
364
|
+
return file?.hosts?.[hostKey(base)] ?? null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Writes through a fresh file and renames it into place. `writeFile` would
|
|
369
|
+
* follow a symlink somebody else planted and would not re-apply the mode to an
|
|
370
|
+
* existing file; `wx` refuses to open anything that already exists.
|
|
371
|
+
*/
|
|
372
|
+
async function writeSecret(path, body) {
|
|
373
|
+
const tmp = `${path}.${process.pid}.${randomB64url(6).replace(/[^A-Za-z0-9]/g, "")}.tmp`;
|
|
374
|
+
const handle = await open(tmp, "wx", 0o600);
|
|
375
|
+
try {
|
|
376
|
+
await handle.writeFile(body);
|
|
377
|
+
} finally {
|
|
378
|
+
await handle.close();
|
|
379
|
+
}
|
|
380
|
+
await rename(tmp, path);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function saveCredentials(base, entry, log) {
|
|
384
|
+
const path = credentialsPath();
|
|
385
|
+
// The directory matters as much as the file: a 0600 file inside a world-
|
|
386
|
+
// writable directory can be unlinked and replaced with someone else's token.
|
|
387
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
388
|
+
const file = (await readCredentialsFile(log)) ?? { version: 1, hosts: {} };
|
|
389
|
+
file.version = 1;
|
|
390
|
+
file.hosts = { ...file.hosts, [hostKey(base)]: entry };
|
|
391
|
+
await writeSecret(path, `${JSON.stringify(file, null, 2)}\n`);
|
|
392
|
+
return path;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function forgetCredentials(base, log) {
|
|
396
|
+
const path = credentialsPath();
|
|
397
|
+
const file = await readCredentialsFile(log);
|
|
398
|
+
if (!file?.hosts?.[hostKey(base)]) return;
|
|
399
|
+
delete file.hosts[hostKey(base)];
|
|
400
|
+
if (Object.keys(file.hosts).length === 0) {
|
|
401
|
+
await rm(path, { force: true });
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
await rm(path, { force: true });
|
|
405
|
+
await writeSecret(path, `${JSON.stringify(file, null, 2)}\n`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---------------------------------------------------------------- login
|
|
409
|
+
|
|
410
|
+
const LOGIN_TIMEOUT_MS = 120_000;
|
|
411
|
+
/** Matches what the Worker will accept; anything else is rejected, not scrubbed. */
|
|
412
|
+
const LABEL_RE = /[^A-Za-z0-9 ._@-]/g;
|
|
413
|
+
|
|
414
|
+
const DONE_HTML = `<!doctype html>
|
|
415
|
+
<meta charset="utf-8">
|
|
416
|
+
<title>Signed in</title>
|
|
417
|
+
<style>
|
|
418
|
+
body { margin:0; min-height:100vh; display:grid; place-items:center; background:#0f1115; color:#e6e8ee;
|
|
419
|
+
font:15px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif; }
|
|
420
|
+
@media (prefers-color-scheme: light) { body { background:#fbfbfd; color:#1a1d24; } }
|
|
421
|
+
</style>
|
|
422
|
+
<p>Signed in. You can close this tab.</p>
|
|
423
|
+
`;
|
|
424
|
+
|
|
425
|
+
function machineLabel() {
|
|
426
|
+
let name = "";
|
|
427
|
+
try {
|
|
428
|
+
name = `${userInfo().username}@${hostname()}`;
|
|
429
|
+
} catch { /* fall through to the default below */ }
|
|
430
|
+
return name.replace(LABEL_RE, "-").slice(0, 40) || "a machine";
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function sameSecret(a, b) {
|
|
434
|
+
const left = Buffer.from(String(a), "utf8");
|
|
435
|
+
const right = Buffer.from(String(b), "utf8");
|
|
436
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function openBrowser(url) {
|
|
440
|
+
const [command, args] =
|
|
441
|
+
process.platform === "darwin"
|
|
442
|
+
? ["open", [url]]
|
|
443
|
+
: process.platform === "win32"
|
|
444
|
+
? ["rundll32", ["url.dll,FileProtocolHandler", url]] // `cmd /c start` mangles &
|
|
445
|
+
: ["xdg-open", [url]];
|
|
446
|
+
try {
|
|
447
|
+
// An argv array, never a shell: the URL contains & and would otherwise
|
|
448
|
+
// break, or worse, be interpreted.
|
|
449
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
450
|
+
child.on("error", () => {}); // no browser here; the URL is already printed
|
|
451
|
+
child.unref();
|
|
452
|
+
} catch { /* same */ }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Listens on a loopback port for the redirect that carries the login code.
|
|
457
|
+
*
|
|
458
|
+
* The port is bound *before* the URL is handed out, so there is no window in
|
|
459
|
+
* which another local process could claim the port we are about to advertise.
|
|
460
|
+
*/
|
|
461
|
+
function awaitCallback(state, onReady) {
|
|
462
|
+
return new Promise((settle, fail) => {
|
|
463
|
+
let code = null;
|
|
464
|
+
const server = createServer((req, res) => {
|
|
465
|
+
const send = (status, body = "", headers = {}) => {
|
|
466
|
+
res.writeHead(status, {
|
|
467
|
+
"content-type": "text/html; charset=utf-8",
|
|
468
|
+
"cache-control": "no-store",
|
|
469
|
+
connection: "close",
|
|
470
|
+
...headers,
|
|
471
|
+
});
|
|
472
|
+
res.end(body);
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const port = server.address()?.port;
|
|
476
|
+
// Only a genuine top-level navigation reaches this. A page doing fetch()
|
|
477
|
+
// sends an Origin; DNS rebinding needs a hostname rather than the literal.
|
|
478
|
+
if (req.method !== "GET") return send(405);
|
|
479
|
+
if (req.headers.origin) return send(403);
|
|
480
|
+
if (req.headers.host !== `127.0.0.1:${port}`) return send(403);
|
|
481
|
+
|
|
482
|
+
const url = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
483
|
+
if (url.pathname === "/done") {
|
|
484
|
+
send(200, DONE_HTML);
|
|
485
|
+
return shutdown();
|
|
486
|
+
}
|
|
487
|
+
if (url.pathname !== "/cb") return send(404);
|
|
488
|
+
|
|
489
|
+
// A callback for a login we did not start: either a stray one or an
|
|
490
|
+
// attempt to hand us somebody else's token. Refuse it and keep waiting —
|
|
491
|
+
// giving up here would let any local process break every login.
|
|
492
|
+
if (!sameSecret(url.searchParams.get("state") ?? "", state)) return send(400);
|
|
493
|
+
|
|
494
|
+
code = url.searchParams.get("code") ?? "";
|
|
495
|
+
// Bounce to /done so the code is not left sitting in the address bar.
|
|
496
|
+
send(302, "", { location: "/done" });
|
|
497
|
+
clearTimeout(timer);
|
|
498
|
+
settle(code);
|
|
499
|
+
// If the browser never asks for /done, stop listening anyway.
|
|
500
|
+
grace = setTimeout(shutdown, 3000);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
let grace = null;
|
|
504
|
+
const shutdown = () => {
|
|
505
|
+
clearTimeout(timer);
|
|
506
|
+
clearTimeout(grace);
|
|
507
|
+
// close() alone waits out keep-alive sockets and the process would hang.
|
|
508
|
+
server.closeAllConnections();
|
|
509
|
+
server.close();
|
|
510
|
+
if (code === null) fail(new Error("login timed out — nothing came back from the browser"));
|
|
511
|
+
};
|
|
512
|
+
const timer = setTimeout(shutdown, LOGIN_TIMEOUT_MS);
|
|
513
|
+
|
|
514
|
+
server.on("error", fail);
|
|
515
|
+
server.listen(0, "127.0.0.1", () => onReady(server.address().port));
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function login(base, log) {
|
|
520
|
+
// PKCE. The verifier never leaves this process — not the command line, not
|
|
521
|
+
// the URL, not the browser, not the loopback hop — so a local process that
|
|
522
|
+
// manages to see the code still cannot spend it.
|
|
523
|
+
const verifier = randomB64url(32);
|
|
524
|
+
const challenge = await sha256B64url(verifier);
|
|
525
|
+
const state = randomB64url(16);
|
|
526
|
+
const machine = machineLabel();
|
|
527
|
+
const match = await userCode(challenge);
|
|
528
|
+
|
|
529
|
+
const code = await awaitCallback(state, (port) => {
|
|
530
|
+
const query = new URLSearchParams({ port: String(port), state, challenge, label: machine });
|
|
531
|
+
const url = `${base}/cli/login?${query}`;
|
|
532
|
+
// Printed before the browser is opened, so a machine without one — over
|
|
533
|
+
// SSH, in a container — needs no special case at all.
|
|
534
|
+
log(`opening ${url}`);
|
|
535
|
+
log("");
|
|
536
|
+
log(` the page should show this code: ${match}`);
|
|
537
|
+
log("");
|
|
538
|
+
if (process.env.SSH_CONNECTION || process.env.SSH_TTY) {
|
|
539
|
+
log("(over SSH: open that URL on the machine you are sitting at, with the port forwarded)");
|
|
540
|
+
}
|
|
541
|
+
openBrowser(url);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
const res = await new Client(base, null).json("POST", "/_api/cli/exchange", { code, verifier, machine });
|
|
545
|
+
const path = await saveCredentials(
|
|
546
|
+
base,
|
|
547
|
+
{ token: res.token, email: res.email, tokenId: res.tokenId, expiresAt: res.expiresAt, machine },
|
|
548
|
+
log,
|
|
549
|
+
);
|
|
550
|
+
// Never the token itself: this lands in scrollback, tmux logs and CI output.
|
|
551
|
+
log(`saved to ${path}`);
|
|
552
|
+
return res;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async function logout(base, opts, log) {
|
|
556
|
+
const entry = await readCredentials(base, log);
|
|
557
|
+
if (!entry) return { revoked: 0, email: null };
|
|
558
|
+
let revoked = 0;
|
|
559
|
+
try {
|
|
560
|
+
const res = await new Client(base, entry.token).json(
|
|
561
|
+
"POST",
|
|
562
|
+
`/_api/cli/logout${opts.all ? "?all=1" : ""}`,
|
|
563
|
+
{},
|
|
564
|
+
);
|
|
565
|
+
revoked = res?.revoked ?? 0;
|
|
566
|
+
} catch (err) {
|
|
567
|
+
// Offline, or the token already expired. Forget it locally regardless —
|
|
568
|
+
// leaving it on disk would be the worse outcome.
|
|
569
|
+
log(`could not reach ${base} (${err.message}) — forgetting the token locally anyway`);
|
|
570
|
+
}
|
|
571
|
+
await forgetCredentials(base, log);
|
|
572
|
+
return { revoked, email: entry.email ?? null };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
async function main() {
|
|
577
|
+
const argv = process.argv.slice(2);
|
|
578
|
+
const command = argv[0];
|
|
579
|
+
if (!command || command === "-h" || command === "--help" || command === "help") {
|
|
580
|
+
console.log(USAGE);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const opts = parseArgs(argv.slice(1));
|
|
585
|
+
if (opts.help) { console.log(USAGE); return; }
|
|
586
|
+
|
|
587
|
+
const log = opts.json ? () => {} : (msg) => process.stderr.write(`${msg}\n`);
|
|
588
|
+
|
|
589
|
+
if (command === "new") {
|
|
590
|
+
const target = opts.positional[0];
|
|
591
|
+
if (!target) throw new Error("new needs a directory to create");
|
|
592
|
+
const dest = await scaffold(target, opts.name, log);
|
|
593
|
+
console.log(opts.json ? JSON.stringify({ dir: dest, name: opts.name ?? TEMPLATE_TITLE }) : dest);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const base = opts.base ?? process.env.DCL_ARTIFACTS_BASE ?? DEFAULT_BASE;
|
|
598
|
+
|
|
599
|
+
if (command === "login") {
|
|
600
|
+
const res = await login(base, log);
|
|
601
|
+
console.log(
|
|
602
|
+
opts.json
|
|
603
|
+
? JSON.stringify({ email: res.email, tokenId: res.tokenId, expiresAt: res.expiresAt }, null, 2)
|
|
604
|
+
: `signed in as ${res.email} — expires ${res.expiresAt.slice(0, 10)}`,
|
|
605
|
+
);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (command === "logout") {
|
|
610
|
+
const res = await logout(base, opts, log);
|
|
611
|
+
console.log(
|
|
612
|
+
opts.json
|
|
613
|
+
? JSON.stringify(res, null, 2)
|
|
614
|
+
: res.email
|
|
615
|
+
? `signed out ${res.email} (${res.revoked} token(s) revoked)`
|
|
616
|
+
: "not signed in",
|
|
617
|
+
);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Flag, then environment, then the file `login` wrote. The environment still
|
|
622
|
+
// wins so CI and scripts/smoke.sh keep working exactly as they did.
|
|
623
|
+
const token = opts.token ?? process.env.DCL_ARTIFACTS_TOKEN ?? (await readCredentials(base, log))?.token ?? "";
|
|
624
|
+
if (!token) {
|
|
625
|
+
throw new Error("not signed in — run: dcl-artifacts login");
|
|
626
|
+
}
|
|
627
|
+
const client = new Client(base, token);
|
|
628
|
+
|
|
629
|
+
switch (command) {
|
|
630
|
+
case "push": {
|
|
631
|
+
if (opts.positional.length === 0) throw new Error("push needs at least one directory or file");
|
|
632
|
+
if (opts.positional.length > 1 && (opts.name || opts.id)) {
|
|
633
|
+
throw new Error("--name and --id apply to a single artifact; drop them when pushing several");
|
|
634
|
+
}
|
|
635
|
+
const results = [];
|
|
636
|
+
for (const target of opts.positional) {
|
|
637
|
+
results.push(await push(target, client, opts, log));
|
|
638
|
+
}
|
|
639
|
+
if (opts.json) {
|
|
640
|
+
console.log(JSON.stringify(results.length === 1 ? results[0] : results, null, 2));
|
|
641
|
+
} else {
|
|
642
|
+
for (const r of results) console.log(r.url);
|
|
643
|
+
}
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
case "info": {
|
|
647
|
+
const id = requireId(opts.positional[0], "info");
|
|
648
|
+
const info = await client.call("GET", `/_api/artifacts/${id}`);
|
|
649
|
+
if (opts.json) { console.log(JSON.stringify(info, null, 2)); return; }
|
|
650
|
+
console.log(`${info.name}\n ${info.url}`);
|
|
651
|
+
console.log(` ${info.visibility}${info.public ? ` — ${info.public.url} (on since ${fmtWhen(info.public.at)} by ${info.public.by})` : " — work-email login"}`);
|
|
652
|
+
for (const v of [...info.versions].reverse()) {
|
|
653
|
+
console.log(` v${v.n}${v.n === info.current ? " (latest)" : " "} ${fmtWhen(v.at)} ${v.files} file(s), ${(v.bytes / 1024).toFixed(1)} KiB${v.by ? ` by ${v.by}` : ""}`);
|
|
654
|
+
}
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
case "whoami": {
|
|
658
|
+
const me = await client.call("GET", "/_api/whoami");
|
|
659
|
+
if (opts.json) { console.log(JSON.stringify(me, null, 2)); return; }
|
|
660
|
+
console.log(
|
|
661
|
+
me.kind === "user"
|
|
662
|
+
? `${me.email} — token ${me.tokenId}, expires ${me.expiresAt.slice(0, 10)}`
|
|
663
|
+
: "the shared upload token — no identity attached",
|
|
664
|
+
);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
case "ls":
|
|
668
|
+
case "list": {
|
|
669
|
+
const res = await client.call("GET", "/_api/artifacts");
|
|
670
|
+
if (opts.json) { console.log(JSON.stringify(res, null, 2)); return; }
|
|
671
|
+
if (res.artifacts.length === 0) console.log("no artifacts");
|
|
672
|
+
for (const a of res.artifacts) {
|
|
673
|
+
console.log(`${a.id} v${String(a.current).padEnd(3)} ${a.visibility.padEnd(7)} ${fmtWhen(a.updatedAt)} ${a.name}`);
|
|
674
|
+
}
|
|
675
|
+
for (const id of res.legacy) console.log(`${id.padEnd(36)} legacy — rm it and push again`);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
case "rm":
|
|
679
|
+
case "delete": {
|
|
680
|
+
const id = requireId(opts.positional[0], "rm");
|
|
681
|
+
const res = await client.call("DELETE", `/_api/artifacts/${id}`);
|
|
682
|
+
console.log(`deleted ${res.deleted} object(s) from ${res.id}${res.revokedAlias ? ` and its public link` : ""}`);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
default:
|
|
686
|
+
throw new Error(`unknown command: ${command}\n\n${USAGE}`);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Run only when invoked as a program, so the module stays importable. Through
|
|
691
|
+
// resolved paths: npm exposes the bin as a symlink in node_modules/.bin, and
|
|
692
|
+
// argv[1] is the symlink while import.meta.url is the file it points at.
|
|
693
|
+
function invokedDirectly() {
|
|
694
|
+
try {
|
|
695
|
+
return realpathSync(process.argv[1] ?? "") === realpathSync(fileURLToPath(import.meta.url));
|
|
696
|
+
} catch {
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (invokedDirectly()) {
|
|
702
|
+
main().catch((err) => {
|
|
703
|
+
process.stderr.write(`error: ${err.message}\n`);
|
|
704
|
+
process.exit(1);
|
|
705
|
+
});
|
|
706
|
+
}
|