Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion .github/workflows/smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,40 @@ echo "ok: preview field validation"
curl -sf -X DELETE "$BASE/api/artifacts/ci-preview" -H "$AUTH" > /dev/null
curl -sf -X DELETE "$BASE/api/artifacts/ci-preview-md" -H "$AUTH" > /dev/null

# --- a patch the server refuses never moves the artifact ---
# The rename moves storage, so every other field has to be parsed first. The 400 on its own was
# already true before this; what it hid was an artifact that had moved anyway, leaving a row whose
# link was dead and a live URL that appeared in no row.
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/artifacts" -H "$AUTH" -H "$JSON" \
-d '{"content":"<h1>halfmove</h1>","type":"html","slug":"ci-halfmove","visibility":"public"}')
expect_code 201 "$code" "publish the rename-refusal artifact"
# One entry per field a patch can set, each carrying a rename the server must not perform.
for bad in '{"slug":"ci-halfmove-2","disabled":"yes"}' '{"slug":"ci-halfmove-2","frame":"on"}' \
'{"slug":"ci-halfmove-2","expiresAt":"not-a-date"}' '{"slug":"ci-halfmove-2","tags":[5]}' \
'{"slug":"ci-halfmove-2","project":5}' '{"slug":"ci-halfmove-2","description":5}' \
'{"slug":"ci-halfmove-2","ogImage":5}' '{"slug":"ci-halfmove-2","visibility":"secret"}' \
'{"slug":"ci-halfmove-2","visibility":"password"}' '{"slug":"ci-halfmove-2","rotateToken":true}'; do
code=$(curl -s -o /dev/null -w '%{http_code}' -X PATCH "$BASE/api/artifacts/ci-halfmove" \
-H "$AUTH" -H "$JSON" -d "$bad")
[ "$code" = 400 ] || fail "refused rename patch $bad: expected 400, got $code"
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/a/ci-halfmove")
[ "$code" = 200 ] || fail "$bad: old slug should still serve, got $code"
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/a/ci-halfmove-2")
[ "$code" = 404 ] || fail "$bad: new slug should be empty, got $code"
[ "$(list_field ci-halfmove slug)" = 'ci-halfmove' ] || fail "$bad: the row lost the artifact"
done
echo "ok: a refused patch leaves the artifact at its old slug, one case per field"
# A patch that names a taken slug still answers 409, ahead of any field the server would refuse.
code=$(curl -s -o /dev/null -w '%{http_code}' -X PATCH "$BASE/api/artifacts/ci-halfmove" \
-H "$AUTH" -H "$JSON" -d '{"slug":"ci-smoke-2","tags":[5]}')
expect_code 409 "$code" "a taken slug wins over a bad field"
# The rename itself still works, so the checks above are not passing on a broken rename.
curl -sf -X PATCH "$BASE/api/artifacts/ci-halfmove" -H "$AUTH" -H "$JSON" \
-d '{"slug":"ci-halfmove-2","tags":["moved"]}' > /dev/null
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/a/ci-halfmove-2")
expect_code 200 "$code" "an accepted rename still moves the artifact"
curl -sf -X DELETE "$BASE/api/artifacts/ci-halfmove-2" -H "$AUTH" > /dev/null

# --- redirects: a real 301 to the stored target, not a JS bounce ---
# %{redirect_url} is curl's parsed Location, so these compare the whole value instead of
# grepping a header line where an unanchored pattern would match a longer target.
Expand Down Expand Up @@ -1309,6 +1343,35 @@ node "$CLI_DIR/cli.js" list --project web-revamp | grep -q 'ci-cli-2' || fail "c
node "$CLI_DIR/cli.js" project ci-cli-2 none > /dev/null
if node "$CLI_DIR/cli.js" list --project web-revamp | grep -q 'ci-cli-2'; then fail "cli project clear failed"; fi
echo "ok: cli project"
# link preview: both fields on publish, then the preview verb on an existing slug. The served
# page is the check, since that is the only place the tags actually have to appear.
url=$(node "$CLI_DIR/cli.js" publish "$ZIPDIR/cli.html" --slug ci-cli-og --visibility public \
--description 'set from the cli' --og-image https://example.com/cli.png)
[ "$url" = "$BASE/a/ci-cli-og" ] || fail "cli publish with preview fields: unexpected url $url"
curl -s "$BASE/a/ci-cli-og" | grep -qF '<meta name="description" content="set from the cli">' \
|| fail "cli --description not stored"
curl -s "$BASE/a/ci-cli-og" | grep -qF '<meta property="og:image" content="https://example.com/cli.png">' \
|| fail "cli --og-image not stored"
node "$CLI_DIR/cli.js" list | grep 'ci-cli-og' | grep -q 'preview' || fail "cli list does not flag a preview"
node "$CLI_DIR/cli.js" update ci-cli-og "$ZIPDIR/cli.html" --description 'set from cli update' > /dev/null
curl -s "$BASE/a/ci-cli-og" | grep -qF '<meta name="description" content="set from cli update">' \
|| fail "cli update --description not stored"
node "$CLI_DIR/cli.js" preview ci-cli-og --description 'edited by the preview verb' > /dev/null
curl -s "$BASE/a/ci-cli-og" | grep -qF '<meta name="description" content="edited by the preview verb">' \
|| fail "cli preview --description did not land"
# The image is untouched by a description-only edit, which is the whole point of one PATCH per field.
curl -s "$BASE/a/ci-cli-og" | grep -qF 'https://example.com/cli.png' || fail "cli preview dropped the image"
node "$CLI_DIR/cli.js" preview ci-cli-og --description none --og-image none > /dev/null
if curl -s "$BASE/a/ci-cli-og" | grep -q 'og:image'; then fail "cli preview none did not clear the image"; fi
if node "$CLI_DIR/cli.js" list | grep 'ci-cli-og' | grep -q 'preview'; then fail "cli list still flags a cleared preview"; fi
# No flag at all is a usage error, not a PATCH that changes nothing.
if node "$CLI_DIR/cli.js" preview ci-cli-og > /dev/null 2>&1; then fail "cli preview with no flag should fail"; fi
# A value the server refuses comes back as its 400, relayed rather than swallowed.
if node "$CLI_DIR/cli.js" preview ci-cli-og --og-image /relative.png > /dev/null 2>&1; then
fail "cli preview --og-image /relative.png should fail"
fi
node "$CLI_DIR/cli.js" delete ci-cli-og > /dev/null
echo "ok: cli link preview"
diff <(qr_local "$BASE/a/ci-cli-2") <(node "$CLI_DIR/cli.js" qr ci-cli-2) > /dev/null \
|| fail "cli qr printed something other than the server's svg"
diff <(qr_local "$BASE/a/ci-cli-2" 3 0) <(node "$CLI_DIR/cli.js" qr ci-cli-2 --scale 3 --margin 0) > /dev/null \
Expand All @@ -1325,9 +1388,12 @@ if node "$CLI_DIR/cli.js" qr ci-cli-2 --png > /dev/null 2>&1; then fail "cli qr
# a bad option is the server's 400, relayed rather than swallowed
if node "$CLI_DIR/cli.js" qr ci-cli-2 --scale 99 > /dev/null 2>&1; then fail "cli qr --scale 99 should fail"; fi
echo "ok: cli qr"
node "$CLI_DIR/cli.js" deploy "$ZIPDIR/site" --slug ci-cli-zip --visibility public > /dev/null
node "$CLI_DIR/cli.js" deploy "$ZIPDIR/site" --slug ci-cli-zip --visibility public \
--description 'a zip from the cli' > /dev/null
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/a/ci-cli-zip/css/s.css")
expect_code 200 "$code" "cli zip deploy"
curl -s "$BASE/a/ci-cli-zip/" | grep -qF '<meta name="description" content="a zip from the cli">' \
|| fail "cli deploy --description not stored"
node "$CLI_DIR/cli.js" delete ci-cli-2 > /dev/null
node "$CLI_DIR/cli.js" delete ci-cli-zip > /dev/null
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/a/ci-cli-2")
Expand Down
45 changes: 41 additions & 4 deletions cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import { artifactExpired } from './lib/expiry.js';
const USAGE = `artifacts — publish to a self-hosted artifacts instance

Usage:
artifacts publish <file> [--slug s] [--title t] [--tags a,b] [--project p] [--expires ISO] [--type html|jsx|tsx|md|redirect] [--frame on|off] [--visibility public|private|password] [--password pw]
artifacts deploy <dir|zip> [--slug s] [--title t] [--tags a,b] [--project p] [--expires ISO] [--visibility public|private|password] [--password pw]
artifacts update <slug> <file> [--title t] [--tags a,b] [--project p] [--type html|jsx|tsx|md|redirect]
artifacts publish <file> [--slug s] [--title t] [--tags a,b] [--project p] [--description d] [--og-image url] [--expires ISO] [--type html|jsx|tsx|md|redirect] [--frame on|off] [--visibility public|private|password] [--password pw]
artifacts deploy <dir|zip> [--slug s] [--title t] [--tags a,b] [--project p] [--description d] [--og-image url] [--expires ISO] [--visibility public|private|password] [--password pw]
artifacts update <slug> <file> [--title t] [--tags a,b] [--project p] [--description d] [--og-image url] [--type html|jsx|tsx|md|redirect]
artifacts list [--tag t] [--project p]
artifacts rename <slug> <new-slug>
artifacts disable <slug>
Expand All @@ -23,6 +23,7 @@ Usage:
artifacts expire <slug> <ISO-date|never>
artifacts tag <slug> <a,b,c|none>
artifacts project <slug> <name|none>
artifacts preview <slug> [--description <d|none>] [--og-image <url|none>]
artifacts delete <slug>
artifacts source <slug> [-o file]
artifacts qr <slug> [--png] [--scale n] [--margin n] [-o file]
Expand All @@ -49,6 +50,8 @@ const { values: opts, positionals } = parseArgs({
tags: { type: 'string' },
tag: { type: 'string' },
project: { type: 'string' },
description: { type: 'string' },
'og-image': { type: 'string' },
expires: { type: 'string' },
scopes: { type: 'string' },
type: { type: 'string' },
Expand Down Expand Up @@ -136,6 +139,8 @@ switch (command) {
...(opts.title && { title: opts.title }),
...(opts.tags !== undefined && { tags: opts.tags }),
...(opts.project !== undefined && { project: opts.project }),
...(opts.description !== undefined && { description: opts.description }),
...(opts['og-image'] !== undefined && { ogImage: opts['og-image'] }),
...(opts.expires && { expiresAt: opts.expires }),
...(opts.frame && opts.frame !== 'default' && { frame: opts.frame === 'on' }),
...(opts.visibility !== undefined && { visibility: opts.visibility }),
Expand All @@ -161,6 +166,8 @@ switch (command) {
if (opts.title) params.set('title', opts.title);
if (opts.tags) params.set('tags', opts.tags);
if (opts.project) params.set('project', opts.project);
if (opts.description) params.set('description', opts.description);
if (opts['og-image']) params.set('ogImage', opts['og-image']);
if (opts.expires) params.set('expiresAt', opts.expires);
if (opts.visibility) params.set('visibility', opts.visibility);
if (opts.password) params.set('password', opts.password);
Expand All @@ -182,6 +189,8 @@ switch (command) {
...(opts.title && { title: opts.title }),
...(opts.tags !== undefined && { tags: opts.tags }),
...(opts.project !== undefined && { project: opts.project }),
...(opts.description !== undefined && { description: opts.description }),
...(opts['og-image'] !== undefined && { ogImage: opts['og-image'] }),
...(opts.visibility !== undefined && { visibility: opts.visibility }),
...(opts.password !== undefined && { password: opts.password }),
});
Expand All @@ -203,7 +212,10 @@ switch (command) {
// printed as "expires [object Object]". Same rule the server serves by.
const expired = artifactExpired(a);
const expiry = expired ? 'expired' : typeof a.expiresAt === 'string' && `expires ${a.expiresAt}`;
const flags = [a.disabled && 'disabled', visFlag, frameFlag, expiry].filter(Boolean);
// One flag for both preview fields: the row has no width for a 300-char description or a
// full image URL, and what a caller wants from a list is which artifacts still have none.
const preview = a.description || a.ogImage ? 'preview' : null;
const flags = [a.disabled && 'disabled', visFlag, frameFlag, preview, expiry].filter(Boolean);
const project = a.project ? `@${a.project}` : '';
const tags = a.tags?.length ? `#${a.tags.join(' #')}` : '';
const meta = [project, tags].filter(Boolean).join(' ');
Expand Down Expand Up @@ -296,6 +308,31 @@ switch (command) {
break;
}

// Both preview fields on one verb, because a link preview reads as one thing and setting the
// image usually means editing the text beside it. Flags rather than positionals, so either
// field can be set on its own; "none" clears it, the way tag and project take none.
case 'preview': {
need(1, '<slug> [--description <d|none>] [--og-image <url|none>]');
const patch = {
...(opts.description !== undefined && {
description: opts.description === 'none' ? '' : opts.description,
}),
...(opts['og-image'] !== undefined && {
ogImage: opts['og-image'] === 'none' ? '' : opts['og-image'],
}),
};
if (!Object.keys(patch).length) fail('preview needs --description or --og-image');
await apiJson('PATCH', `/api/artifacts/${args[0]}`, patch);
// Echoed under the flag name, not the API field name, so the line names what was typed.
if ('description' in patch) {
console.log(patch.description ? `${args[0]} description: ${patch.description}` : `${args[0]} description cleared`);
}
if ('ogImage' in patch) {
console.log(patch.ogImage ? `${args[0]} og-image: ${patch.ogImage}` : `${args[0]} og-image cleared`);
}
break;
}

case 'delete': {
need(1, '<slug>');
await apiJson('DELETE', `/api/artifacts/${args[0]}`);
Expand Down
28 changes: 25 additions & 3 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ export ARTIFACTS_API_KEY=...
## Commands

```
artifacts publish <file> [--slug s] [--title t] [--tags a,b] [--project p] [--expires ISO] [--type html|jsx|tsx|md|redirect] [--frame on|off] [--visibility public|private|password] [--password pw]
artifacts deploy <dir|zip> [--slug s] [--title t] [--tags a,b] [--project p] [--expires ISO] [--visibility public|private|password] [--password pw]
artifacts update <slug> <file> [--title t] [--tags a,b] [--project p]
artifacts publish <file> [--slug s] [--title t] [--tags a,b] [--project p] [--description d] [--og-image url] [--expires ISO] [--type html|jsx|tsx|md|redirect] [--frame on|off] [--visibility public|private|password] [--password pw]
artifacts deploy <dir|zip> [--slug s] [--title t] [--tags a,b] [--project p] [--description d] [--og-image url] [--expires ISO] [--visibility public|private|password] [--password pw]
artifacts update <slug> <file> [--title t] [--tags a,b] [--project p] [--description d] [--og-image url]
artifacts list [--tag t] [--project p]
artifacts rename <slug> <new-slug>
artifacts disable <slug> | enable <slug>
Expand All @@ -32,6 +32,7 @@ artifacts rotate <slug> # invalidate every share link already handed ou
artifacts expire <slug> <ISO-date|never>
artifacts tag <slug> <a,b,c|none>
artifacts project <slug> <name|none>
artifacts preview <slug> [--description <d|none>] [--og-image <url|none>]
artifacts delete <slug>
artifacts source <slug> [-o file]
artifacts qr <slug> [--png] [--scale n] [--margin n] [-o file]
Expand All @@ -53,6 +54,24 @@ default 8) and `--margin` the quiet zone in modules (0 to 8, default 4).

Mint scoped bearer tokens for CLI/MCP clients instead of sharing the bootstrap key. `keys create` prints the full token once (store it) — the server keeps only a hash. `--scopes` defaults to `publish`. See [Auth & API keys](auth.md).

## Link previews

`--description` and `--og-image` set what a chat app or a social card shows when someone pastes the
link. Both take a value on `publish`, `deploy` and `update`, and `artifacts preview <slug>` sets
either one on an artifact that is already up:

```bash
artifacts preview hello --description "Q3 numbers, one page"
artifacts preview hello --og-image https://cdn.example.com/card.png
artifacts preview hello --og-image none # clear just the image
```

Either flag on its own leaves the other field alone. `none` clears a field, the same word `tag` and
`project` take. The image has to be an absolute `http://` or `https://` URL, and a description is
capped at 300 characters after whitespace collapses. `artifacts list` marks a row `preview` when
either field is set. Which pages actually carry the tags is in [formats](formats.md); an artifact
served with no frame carries its stored bytes untouched, so it carries no tags either.

## Projects

A **project** groups artifacts built for the same thing (one project per artifact, distinct from tags). Set it with `publish --project acme-redesign`, change it with `artifacts project <slug> <name>` (`none` clears it), and list a project with `artifacts list --project acme-redesign`. The web UI groups the published list into collapsible sections per project, with a search box across project / title / slug / tags.
Expand Down Expand Up @@ -88,6 +107,9 @@ artifacts list --tag demo # only artifacts tagged "demo"
artifacts project hello acme-redesign # file it under a project
artifacts list --project acme-redesign # only that project's artifacts

artifacts preview hello --description "Q3 numbers, one page" # link preview text
artifacts preview hello --og-image none # drop the preview image

artifacts config --frame-enabled true --frame-default true # turn the frame on globally
artifacts frame hello off # no frame for this one artifact
artifacts frame hello default # back to inheriting the default
Expand Down
Loading
Loading