Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@zuroku/cli

Package Overview
Dependencies
Maintainers
1
Versions
9
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@zuroku/cli - npm Package Compare versions

Package version was removed
This package version has been unpublished, mostly likely due to security reasons
Comparing version
0.1.4
to
0.1.8
+14
.claude-plugin/marketplace.json
{
"name": "zuroku",
"owner": {
"name": "AI-Driven-R-D-Dept"
},
"description": "zuroku publisher skills for Claude Code.",
"plugins": [
{
"name": "zuroku",
"source": "./",
"description": "zuroku-publish + run-explainer-page skills. Includes a bundled `zuroku` CLI wrapper on PATH."
}
]
}
{
"name": "zuroku",
"description": "Publish AI-generated graphic-recording HTML + assets to your zuroku instance. Bundles the zuroku-publish and run-explainer-page skills.",
"version": "0.1.7",
"author": {
"name": "AI-Driven-R-D-Dept"
},
"homepage": "https://github.com/AI-Driven-R-D-Dept/zuroku-cli#readme",
"repository": "https://github.com/AI-Driven-R-D-Dept/zuroku-cli.git",
"license": "MIT"
}

Sorry, the diff of this file is not supported yet

+281
-19

@@ -78,3 +78,3 @@ #!/usr/bin/env node

// src/commands/publish.ts
import { promises as fs } from "fs";
import { promises as fs2 } from "fs";
import path from "path";

@@ -186,2 +186,77 @@ import {

// src/lib/referrer-policy.ts
var TAG_RE = /<(img|iframe)\b([^>]*)>/gi;
function buildLineLookup(html) {
const lineStartOffsets = [0];
for (let i = 0; i < html.length; i++) {
if (html.charCodeAt(i) === 10) lineStartOffsets.push(i + 1);
}
return (offset) => {
let lo = 0;
let hi = lineStartOffsets.length - 1;
while (lo < hi) {
const mid = lo + hi + 1 >>> 1;
if (lineStartOffsets[mid] <= offset) lo = mid;
else hi = mid - 1;
}
return lo + 1;
};
}
function ensureNoReferrerForExternal(html) {
const added = [];
const warnings = [];
const lineFor = buildLineLookup(html);
const out = html.replace(TAG_RE, (full, tagName, attrs, offset) => {
const lowerTag = tagName.toLowerCase();
const srcMatch = /\bsrc\s*=\s*["']([^"']+)["']/i.exec(attrs);
if (!srcMatch) return full;
const src = srcMatch[1];
if (!/^https?:\/\//i.test(src)) return full;
const rpMatch = /\breferrerpolicy\s*=\s*["']([^"']*)["']/i.exec(attrs);
if (rpMatch) {
const existing = rpMatch[1].trim().toLowerCase();
if (existing !== "no-referrer") {
warnings.push({ tag: lowerTag, src, existing, line: lineFor(offset) });
}
return full;
}
const insertAt = srcMatch.index + srcMatch[0].length;
const newAttrs = attrs.slice(0, insertAt) + ' referrerpolicy="no-referrer"' + attrs.slice(insertAt);
added.push({ tag: lowerTag, src, line: lineFor(offset) });
return `<${tagName}${newAttrs}>`;
});
return { html: out, added, warnings };
}
// src/lib/thumbnail.ts
import { promises as fs } from "fs";
import sharp from "sharp";
var THUMB_RE = /^thumb\.(png|jpe?g|webp|gif)$/i;
var JPEG_QUALITY = 82;
var DEFAULT_MAX_LONG_EDGE = 2e3;
function isThumbName(basename) {
return THUMB_RE.test(basename);
}
function isGifThumb(basename) {
return /\.gif$/i.test(basename);
}
async function compressThumbToJpeg(srcPath, opts = {}) {
const maxLongEdge = opts.maxLongEdge ?? DEFAULT_MAX_LONG_EDGE;
const input = await fs.readFile(srcPath);
const pipeline = sharp(input, { failOn: "none" }).rotate();
const meta = await pipeline.metadata();
const w = meta.width ?? 0;
const h = meta.height ?? 0;
const longEdge = Math.max(w, h);
if (longEdge > maxLongEdge && longEdge > 0) {
if (w >= h) {
pipeline.resize({ width: maxLongEdge, withoutEnlargement: true });
} else {
pipeline.resize({ height: maxLongEdge, withoutEnlargement: true });
}
}
const buffer = await pipeline.jpeg({ quality: JPEG_QUALITY, mozjpeg: true }).toBuffer();
return { buffer, contentType: "image/jpeg", filename: "thumb.jpg" };
}
// src/commands/publish.ts

@@ -197,3 +272,3 @@ var HTML_MAX_BYTES = 5 * 1024 * 1024;

out = out.replace(
new RegExp(`(?:img|images)/${escaped}(?=["'\\s),])`, "g"),
new RegExp(`(?:img|images)/${escaped}(?=["'\\s),>])`, "g"),
`img/${to}`

@@ -204,2 +279,60 @@ );

}
function rewriteHtmlToServerAssets(html, serverFilenames) {
const exact = new Set(serverFilenames);
const stemMap = /* @__PURE__ */ new Map();
const ambiguousStems = /* @__PURE__ */ new Set();
for (const f of serverFilenames) {
const stem = f.replace(/\.[^.]+$/, "");
if (stemMap.has(stem) && stemMap.get(stem) !== f) ambiguousStems.add(stem);
else stemMap.set(stem, f);
}
const rewritten = /* @__PURE__ */ new Set();
const unmatched = /* @__PURE__ */ new Set();
const LOCAL_REF = /^(?:\.\/)?(?:img|images)\/([A-Za-z0-9._@()\-]+\.[A-Za-z0-9]+)([?#][^\s]*)?$/;
const resolveRef = (raw) => {
const v = raw.trim();
if (/^(?:[a-z][a-z0-9+.\-]*:|\/\/|#|data:)/i.test(v)) return raw;
const m = v.match(LOCAL_REF);
if (!m) return raw;
const fname = m[1];
const suffix = m[2] ?? "";
let target;
if (exact.has(fname)) {
target = fname;
} else {
const stem = fname.replace(/\.[^.]+$/, "");
if (!ambiguousStems.has(stem)) target = stemMap.get(stem);
}
if (!target) {
unmatched.add(v);
return raw;
}
const next = `img/${target}${suffix}`;
if (next !== v) rewritten.add(v);
return next;
};
let out = html;
out = out.replace(
/\b(src|href|poster)(\s*=\s*)(["'])([^"']*)\3/gi,
(_m, attr, eq, q, val) => `${attr}${eq}${q}${resolveRef(val)}${q}`
);
out = out.replace(
/\b(src|href|poster)(\s*=\s*)([^"'\s>]+)/gi,
(_m, attr, eq, val) => `${attr}${eq}${resolveRef(val)}`
);
out = out.replace(
/\bsrcset(\s*=\s*)(["'])([^"']*)\2/gi,
(_m, eq, q, val) => {
const cands = val.split(",").map((c) => {
const seg = c.trim();
if (!seg) return "";
const sp = seg.split(/\s+/);
sp[0] = resolveRef(sp[0]);
return sp.join(" ");
}).filter((c) => c.length > 0);
return `srcset${eq}${q}${cands.join(", ")}${q}`;
}
);
return { html: out, rewritten: [...rewritten], unmatched: [...unmatched] };
}
function extractHtmlAssetRefs(html) {

@@ -278,3 +411,3 @@ const references = [];

"-V, --visibility <mode>",
"Visibility: private | curator. Server reserves 'public' and rejects it. Falls back to --private then ~/.config/zuroku/config.json then server default (curator) when omitted."
"Visibility: private | curator | public. 'public' is viewable by anyone with the link (kept out of timeline/search, served noindex); it can be set per publish but not stored as a config default. Falls back to --private then ~/.config/zuroku/config.json then server default (curator) when omitted."
).option("--private", "Shortcut for --visibility private (wins over --visibility if both are set)").action(async (htmlArg, images, opts) => {

@@ -286,3 +419,3 @@ try {

try {
htmlStat = await fs.stat(htmlPath);
htmlStat = await fs2.stat(htmlPath);
} catch {

@@ -301,3 +434,3 @@ throw new ZurokuError3("NOT_FOUND", 0, `HTML file not found: ${htmlArg}`);

}
const htmlBuf = await fs.readFile(htmlPath);
const htmlBuf = await fs2.readFile(htmlPath);
info(`html: ${path.basename(htmlPath)} (${htmlStat.size} bytes)`);

@@ -309,3 +442,15 @@ const assets = [];

const label = path.basename(abs);
const payload = opts.compress ? await compressForUpload(abs) : await passthroughForUpload(abs);
let payload;
if (opts.compress && isThumbName(label) && !isGifThumb(label)) {
payload = await compressThumbToJpeg(abs);
} else if (opts.compress) {
payload = await compressForUpload(abs);
} else {
payload = await passthroughForUpload(abs);
if (isThumbName(label) && payload.contentType === "image/webp") {
warn(
"thumb.* \u304C WebP \u3067\u3059\u3002OG/SNS unfurl (LinkedIn/Facebook/LINE) \u3067\u8868\u793A\u3055\u308C\u306A\u3044\u5834\u5408\u304C\u3042\u308A\u307E\u3059\u3002\u30B5\u30E0\u30CD\u306F PNG/JPEG \u63A8\u5968\u3002"
);
}
}
info(

@@ -325,6 +470,18 @@ `asset: ${label} -> ${payload.filename} (${payload.buffer.byteLength} bytes, ${payload.contentType})`

const providedFilenames = assets.map((a) => a.filename);
const htmlText = rewriteHtmlForRename(htmlOriginal, renameMap, providedFilenames);
let htmlText = rewriteHtmlForRename(htmlOriginal, renameMap, providedFilenames);
if (htmlText !== htmlOriginal) {
info(`html: rewrote img src references (path normalize / extension rename)`);
}
const rp = ensureNoReferrerForExternal(htmlText);
htmlText = rp.html;
if (rp.added.length > 0) {
info(
`html: added referrerpolicy="no-referrer" to ${rp.added.length} external <img>/<iframe> (hotlink protection)`
);
}
for (const w of rp.warnings) {
warn(
`<${w.tag}> at line ${w.line} has referrerpolicy="${w.existing}" (recommended: "no-referrer" for hotlink-protected hosts): ${w.src}`
);
}
const htmlForUpload = htmlText !== htmlOriginal ? Buffer.from(htmlText, "utf8") : htmlBuf;

@@ -406,3 +563,3 @@ if (process.env.ZUROKU_SKIP_PREFLIGHT !== "1") {

// src/commands/update.ts
import { promises as fs2 } from "fs";
import { promises as fs3 } from "fs";
import path2 from "path";

@@ -415,3 +572,3 @@ import {

var HTML_MAX_BYTES2 = 5 * 1024 * 1024;
var ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/i;
var ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

@@ -457,10 +614,54 @@ function looksLikeId(s) {

}
async function fetchProjectAssetFilenames(config, projectId) {
const base = config.base_url.replace(/\/+$/, "");
const res = await fetch(`${base}/api/projects/${encodeURIComponent(projectId)}`, {
headers: { Accept: "application/json", Authorization: `Bearer ${config.token}` }
});
if (!res.ok) {
throw new Error(`GET /api/projects/${projectId} -> ${res.status}`);
}
const j = await res.json();
return new Set((j.assets ?? []).map((a) => a.filename));
}
async function patchProjectTitle(config, projectId, title) {
const base = config.base_url.replace(/\/+$/, "");
const res = await fetch(`${base}/api/projects/${encodeURIComponent(projectId)}`, {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${config.token}`
},
body: JSON.stringify({ title })
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new ZurokuError4(
j.error?.code ?? "HTTP_ERROR",
res.status,
`title update failed: ${j.error?.message ?? res.statusText}`
);
}
}
function registerUpdateCommand(parent) {
parent.command("update").description("Republish an existing project, keeping its slug/URL (HTML + asset replace)").argument("<slug-or-id>", "Existing project slug or id (URL part after /p/)").argument("<html>", "Path to the new HTML file (<= 5 MiB)").argument("[images...]", "Image files to upload as the new asset set (full replacement)").option("--no-compress", "Skip image compression (upload originals)").option("-u, --base-url <url>", "Override API base URL").action(async (slugOrId, htmlArg, images, opts) => {
parent.command("update").description("Republish an existing project, keeping its slug/URL (HTML + asset replace)").argument("<slug-or-id>", "Existing project slug or id (URL part after /p/)").argument("<html>", "Path to the new HTML file (<= 5 MiB)").argument("[images...]", "Image files to upload as the new asset set (full replacement)").option("--no-compress", "Skip image compression (upload originals)").option(
"--keep-assets",
"Update HTML only and keep all existing images untouched (ignores [images...])"
).option(
"-T, --title <title>",
"Also change the project's registered title (otherwise the title is kept from the original publish)"
).option("-u, --base-url <url>", "Override API base URL").action(async (slugOrId, htmlArg, images, opts) => {
try {
const config = await loadRuntimeConfig({ ...opts.baseUrl ? { baseUrl: opts.baseUrl } : {} });
if (opts.title !== void 0) {
const t = opts.title.trim();
if (!t) throw new ZurokuError4("INVALID_INPUT", 0, "--title must not be empty");
if (t.length > 200) {
throw new ZurokuError4("INVALID_INPUT", 0, `--title exceeds 200 chars (got ${t.length})`);
}
}
const htmlPath = path2.resolve(htmlArg);
let htmlStat;
try {
htmlStat = await fs2.stat(htmlPath);
htmlStat = await fs3.stat(htmlPath);
} catch {

@@ -479,10 +680,27 @@ throw new ZurokuError4("NOT_FOUND", 0, `HTML file not found: ${htmlArg}`);

}
const htmlBuf = await fs2.readFile(htmlPath);
const htmlBuf = await fs3.readFile(htmlPath);
info(`html: ${path2.basename(htmlPath)} (${htmlStat.size} bytes)`);
const assets = [];
const renameMap = [];
for (const img of images) {
if (opts.keepAssets && images.length > 0) {
warn(
`--keep-assets specified: ignoring ${images.length} image arg(s); existing images are kept as-is`
);
}
for (const img of opts.keepAssets ? [] : images) {
const abs = path2.resolve(img);
const label = path2.basename(abs);
const payload = opts.compress ? await compressForUpload2(abs) : await passthroughForUpload2(abs);
let payload;
if (opts.compress && isThumbName(label) && !isGifThumb(label)) {
payload = await compressThumbToJpeg(abs);
} else if (opts.compress) {
payload = await compressForUpload2(abs);
} else {
payload = await passthroughForUpload2(abs);
if (isThumbName(label) && payload.contentType === "image/webp") {
warn(
"thumb.* \u304C WebP \u3067\u3059\u3002OG/SNS unfurl (LinkedIn/Facebook/LINE) \u3067\u8868\u793A\u3055\u308C\u306A\u3044\u5834\u5408\u304C\u3042\u308A\u307E\u3059\u3002\u30B5\u30E0\u30CD\u306F PNG/JPEG \u63A8\u5968\u3002"
);
}
}
info(

@@ -502,7 +720,19 @@ `asset: ${label} -> ${payload.filename} (${payload.buffer.byteLength} bytes, ${payload.contentType})`

const providedFilenames = assets.map((a) => a.filename);
const htmlText = rewriteHtmlForRename(htmlOriginal, renameMap, providedFilenames);
let htmlText = rewriteHtmlForRename(htmlOriginal, renameMap, providedFilenames);
if (htmlText !== htmlOriginal) {
info(`html: rewrote img src references (path normalize / extension rename)`);
}
const htmlForUpload = htmlText !== htmlOriginal ? Buffer.from(htmlText, "utf8") : htmlBuf;
const rp = ensureNoReferrerForExternal(htmlText);
htmlText = rp.html;
if (rp.added.length > 0) {
info(
`html: added referrerpolicy="no-referrer" to ${rp.added.length} external <img>/<iframe> (hotlink protection)`
);
}
for (const w of rp.warnings) {
warn(
`<${w.tag}> at line ${w.line} has referrerpolicy="${w.existing}" (recommended: "no-referrer" for hotlink-protected hosts): ${w.src}`
);
}
let htmlForUpload = htmlText !== htmlOriginal ? Buffer.from(htmlText, "utf8") : htmlBuf;
if (process.env.ZUROKU_SKIP_PREFLIGHT !== "1") {

@@ -518,3 +748,3 @@ const leaks = scanLocalPathLeaks(htmlText);

}
if (process.env.ZUROKU_SKIP_PREFLIGHT !== "1") {
if (!opts.keepAssets && process.env.ZUROKU_SKIP_PREFLIGHT !== "1") {
const { references, expectedFilenames } = extractHtmlAssetRefs(htmlText);

@@ -540,7 +770,33 @@ const provided = new Set(providedFilenames);

info(`project_id=${projectId}`);
info(`republish-init: declaring ${assets.length} asset(s)...`);
if (opts.keepAssets) {
try {
const existing = await fetchProjectAssetFilenames(config, projectId);
const rw = rewriteHtmlToServerAssets(htmlText, [...existing]);
if (rw.rewritten.length > 0) {
htmlText = rw.html;
htmlForUpload = Buffer.from(htmlText, "utf8");
info(
`html: rewrote ${rw.rewritten.length} local img ref(s) to existing server assets (keep-assets)`
);
}
if (rw.unmatched.length > 0) {
warn(
"--keep-assets: HTML references images not present on the server (these will 404 \u2014 fix the refs or re-run without --keep-assets and pass every image):"
);
for (const u of rw.unmatched) warn(` - ${u}`);
}
} catch (e) {
warn(
`--keep-assets: could not verify existing assets (${e.message ?? String(e)}); skipping reference check`
);
}
}
const initBody = opts.keepAssets ? { keep_assets: true } : { asset_filenames: assets.map((a) => a.filename) };
info(
opts.keepAssets ? "republish-init: keep-assets mode (HTML only, existing images preserved)..." : `republish-init: declaring ${assets.length} asset(s)...`
);
const init = await callRepublishApi(
config,
`/api/projects/${encodeURIComponent(projectId)}/republish-init`,
{ asset_filenames: assets.map((a) => a.filename) }
initBody
);

@@ -562,2 +818,8 @@ info("uploading html + assets...");

success(`updated: slug=${res.slug} id=${res.id}`);
if (opts.title !== void 0 && opts.title.trim()) {
const t = opts.title.trim();
info(`updating registered title to "${t}"...`);
await patchProjectTitle(config, projectId, t);
success("title updated");
}
process.stdout.write(`${res.url}

@@ -564,0 +826,0 @@ `);

+5
-2
{
"name": "@zuroku/cli",
"version": "0.1.4",
"version": "0.1.8",
"type": "module",

@@ -12,2 +12,4 @@ "description": "Command-line publisher for zuroku — upload AI-generated graphic-recording HTML + assets to your zuroku instance.",

"skills",
"bin",
".claude-plugin",
"README.md",

@@ -48,3 +50,4 @@ "LICENSE"

"commander": "^12.1.0",
"kleur": "^4.1.5"
"kleur": "^4.1.5",
"sharp": "^0.33.5"
},

@@ -51,0 +54,0 @@ "devDependencies": {

@@ -31,7 +31,7 @@ # @zuroku/cli

# 2. Make sure your HTML references images as `<img src="img/foo.png">`.
# If your generator emits `images/...`, rewrite it first:
# 2. Collect the HTML and its images into one directory and publish.
# `images/<file>` references are auto-normalized to `img/<file>` for your
# provided assets — no manual sed needed.
mkdir -p /tmp/zuroku-deploy
sed 's|images/|img/|g' /path/to/index.html > /tmp/zuroku-deploy/index.html
cp /path/to/images/*.png /tmp/zuroku-deploy/
cp /path/to/index.html /path/to/images/*.png /tmp/zuroku-deploy/

@@ -41,6 +41,13 @@ cd /tmp/zuroku-deploy

# - assets are compressed to WebP client-side (sharp, 85% quality, max 2000px long-edge)
# - <img src="img/foo.png"> in the HTML is auto-rewritten to .webp on the fly (since v0.1.1)
# - `images/foo.png` / `img/foo.png` are auto-rewritten to `img/foo.webp` (anchored to
# your provided assets — external URLs that contain `images/` are left intact)
# - stdout last line is the public URL
```
> **Do not run a bare `sed 's|images/|img/|g'`.** It rewrites the substring
> `images/` everywhere, including external image URLs like
> `https://.../images/foo.webp`, turning them into broken `.../img/...` links (404).
> The CLI normalizes local references safely on its own. If you must hand-edit a
> different prefix (e.g. `assets/`), anchor to `src="assets/` so external URLs are untouched.
## Commands

@@ -93,2 +100,3 @@

- `curator` — viewable by Discord members with the configured curator role.
- `public` — viewable by anyone with the link (not just curators). Still kept out of the in-app timeline/search, and served with `X-Robots-Tag: noindex,nofollow` (link sharing / SNS unfurl only, no SEO indexing).

@@ -104,3 +112,3 @@ Order of precedence: CLI flag (`--private` > `--visibility`) → per-user config `default-visibility` → server default (`curator`).

`public` is reserved by the server and rejected by both CLI and API. To make something publicly visible, publish as `curator` and toggle visibility from the web UI.
`public` can be set per publish (`zuroku publish … --visibility public`), but **cannot** be stored as a `default-visibility` config value — public exposure should always be a deliberate per-publish choice, never a silent default. You can also toggle visibility (including to/from `public`) later from the web UI.

@@ -107,0 +115,0 @@ ## Output contract

@@ -253,2 +253,21 @@ ---

### 外部 subresource (`<img src="https://...">` / `<iframe>`)
原則として **外部画像を直貼りしない**。本 skill は `run-ai-images` で生成した
ローカル画像 (`images/concept-NN-01.png`) を asset として upload する 1 枚 HTML が
基本形。公式ロゴ等を引きたい場面で外部 `<img>` を埋め込むときは **必ず**
`referrerpolicy="no-referrer"` を付ける:
```html
<img src="https://example.com/logo.png" referrerpolicy="no-referrer" alt="...">
```
理由は **hotlink protection (Referer 検査)** 対策。zuroku 配信ドメインを
Referer に載せると X / 一部 CDN / 報道サイトが 403 / placeholder を返す。
`curl` だと 200 が返るのでローカル検証では気付けない (= 初見エージェントの
頻出ミス)。zuroku CLI v0.1.5+ は publish/update 時に自動付与するが、
HTML 生成段階で明示しておくと preflight diff が読みやすい。
`<a href="https://...">` (ナビゲーション) には不要。
### アクセント色の決め方

@@ -283,2 +302,3 @@

- **数字には出典** → Phase 1 で取得した価格 / 性能数字は出典 URL を outline に入れて `<p style="font-size: 13px; color: var(--ink-2);">※ 出典: ...</p>` で section 末尾に置く。出典なしの数字を断定形で書かない
- **外部画像を貼るなら `referrerpolicy="no-referrer"`** → `<img src="https://...">` を埋めるとき、公開ドメインから読まれた瞬間に Referer ベースの hotlink protection で 403 / placeholder になる (X / 一部 CDN / 報道サイト)。`curl` で 200 が返るので「リンク切れ」と誤認しやすい。CLI v0.1.5+ は自動付与するが、HTML 側で明示しておくのが事故率最低。`<a>` には不要

@@ -285,0 +305,0 @@ ## Additional resources

@@ -7,3 +7,3 @@ ---

author: AI-Driven-R-D-Dept
version: '0.1.3'
version: '0.1.8'
user-invocable: true

@@ -21,11 +21,12 @@ argument-hint: <html-path> [image-paths...] --title "..." [--no-compress] [--visibility private|curator] [--private]

```bash
# HTML 内の <img src> を `img/<basename>` 形式に揃える (img/ prefix 必須)
# HTML と画像をデプロイ用ディレクトリに集める (sed での書き換えは原則不要、下の注意参照)
mkdir -p /tmp/zuroku-deploy
sed 's|images/|img/|g' /path/to/index.html > /tmp/zuroku-deploy/index.html
cp /path/to/images/*.png /tmp/zuroku-deploy/
cp /path/to/index.html /path/to/images/*.png /tmp/zuroku-deploy/
cd /tmp/zuroku-deploy
zuroku publish ./index.html ./*.png --title "..."
# - sharp で client-side WebP 圧縮 (85%、長辺 2000px) → 通信量 / 表示も軽量
# - HTML 内 <img src="img/foo.png"> は CLI が自動で .webp に rewrite (v0.1.1+)
# - `images/<画像>` 参照は CLI が **provided asset の filename にアンカーして**
# 自動で `img/<画像>` に正規化する。手動 sed は不要 (外部 URL は壊さない)。
# - sharp で client-side WebP 圧縮 (85%、長辺 2000px)。<img src="img/foo.png"> も
# CLI が自動で .webp に rewrite (v0.1.1+)。
# - 標準出力の最終行が公開 URL

@@ -37,2 +38,8 @@

> ⚠️ **`sed 's|images/|img/|g'` のような bare 置換は使わない。** 文字列 `images/`
> をどこでも置換するため、HTML 内の外部画像 URL
> (`https://.../images/foo.webp` 等) まで `.../img/...` に化けさせて 404 にする。
> ローカル参照の正規化は CLI が安全に行うので不要。どうしても手動で直す必要がある
> 場合 (後述 `assets/` 等) は `src="images/` のように**ローカル参照だけにアンカー**すること。
## 制約

@@ -42,3 +49,4 @@

- 配信ルートが `/p/:slug/img/:filename` 固定。HTML 側は相対 `<img src="img/foo.png">` で参照する。
- `images/`、`./assets/` 等で書かれていれば publish 前に sed で書き換える。
- **`images/<画像>` (複数形) は CLI が provided asset について自動で `img/` に正規化する**ので手動修正は不要。外部 URL (`https://.../images/...`) は provided にアンカーされるため触られない。
- `assets/`、`style/` 等の**別ディレクトリ名**は自動正規化の対象外。必要なら `src="assets/` のように**ローカル参照だけにアンカー**して書き換える。**bare `images/` を global 置換しないこと** (外部 URL 内の `images/` まで壊して 404 になる)。
- SVG (`<img src="img/foo.svg">`) は受け付けない。PNG / JPEG / WebP / GIF のみ。

@@ -52,2 +60,12 @@

### R2.5. サムネ / OG 画像 (`thumb.*` 規約)
- OG 画像 (SNS unfurl / アプリ内一覧のサムネ) は **`thumb.{png|jpg|jpeg|webp|gif}` という名前の画像**が自動で選ばれる。専用フラグは無く、ファイル名規約で指定する。
- `thumb.*` が無ければ asset の **1 枚目** (`created_at`→`filename` 昇順の先頭) に自動フォールバックする。意図しない画像がサムネになりがちなので、サムネを効かせたい記事では必ず `thumb.*` を用意する。
- **どの画像を `thumb` にするか**: その記事の **全体像を最もよく表す 1 枚** を選んで `thumb.*` にリネームして含める。
- 良い例: 図解全体の俯瞰図 / 完成形のキービジュアル / 記事の結論を 1 枚で示す図。
- 避ける: 部分拡大・補足の細部図・文脈なしでは意味が伝わらない断片。SNS のカードや一覧で「これは何の記事か」が一目で伝わる 1 枚を選ぶ。
- **`thumb.*` は OG/SNS unfurl 互換のため CLI が自動で JPEG (`thumb.jpg`) に変換する** (v0.1.8+)。他の asset は WebP 圧縮されるが、サムネだけは LinkedIn / Facebook / LINE 等が WebP の og:image を描画しない問題を避けるため JPEG に揃える (Slack/Discord は WebP でも可)。HTML 内の `img/thumb.png` 参照も `img/thumb.jpg` に自動 rewrite される。
- GIF の `thumb.gif` はアニメ保持のため変換しない。`--no-compress` で WebP の thumb を渡すと warn が出る (OG が表示されない可能性)。
- `update` の全置換でも `thumb.*` を含めれば再選定される。`--keep-assets` では既存のサムネがそのまま維持される。
### R3. サイズ上限

@@ -59,3 +77,3 @@ - HTML: 5 MiB

### R4. visibility (公開範囲)
- `-V, --visibility <mode>`: `private` (本人のみ) / `curator` (curator role を持つ Discord メンバーのみ閲覧可)。
- `-V, --visibility <mode>`: `private` (本人のみ) / `curator` (curator role を持つ Discord メンバーのみ閲覧可) / `public` (リンクを知る誰でも閲覧可)。
- `--private`: `--visibility private` のショートカット。`-V curator` と併用された場合は `--private` が勝つ (CLI が warn を出す)。

@@ -66,4 +84,5 @@ - どちらも未指定なら順に下記の優先順で解決:

3. server default = `curator`
- **`public` は server 予約語**で、CLI も server も reject する。一般公開したい場合は curator にした上で Web UI から個別操作する。
- private のまま publish 後に visibility を変えたいときは stderr に表示される `manage visibility: <app>/settings/projects` の URL から切り替える。
- **`public` は per-publish で指定可能** (`--visibility public`)。リンクを知る誰でも閲覧できるが、アプリ内 timeline/検索には出さず `X-Robots-Tag: noindex,nofollow` で配信される (リンク共有 / SNS unfurl 用、SEO index はしない)。
- ただし **`public` を `config set default-visibility` のデフォルトには保存できない** (公開は毎回明示的に選ぶべきで、暗黙のデフォルトにはしない設計)。
- private のまま publish 後に visibility を変えたいときは stderr に表示される `manage visibility: <app>/settings/projects` の URL から切り替える (Web UI からは public への切替も可)。

@@ -78,6 +97,25 @@ ## CLI が publish 前に弾くケース

| `[UNUSED]` | asset 引数にあるが HTML 未参照 | 余分な image を引数から外す |
| `[WRONG-PATH]` | `img/` で始まらない相対参照 | sed で `images/` → `img/` 等に書換 |
| `[WRONG-PATH]` | `img/` で始まらない相対参照 | `src="images/` のように**ローカル参照だけにアンカー**して書換 (bare `images/` の global 置換は外部 URL を壊すので不可) |
緊急 bypass: `ZUROKU_SKIP_PREFLIGHT=1 zuroku publish ...` (debug 用、本番では使わない)。
### R6. 外部 subresource の hotlink protection (v0.1.5+, 自動)
zuroku CLI は publish/update 時に HTML を scan し、`<img src="https://...">` と
`<iframe src="https://...">` に `referrerpolicy="no-referrer"` が無ければ
自動付与する。理由:
- 配信ドメイン (例: `app.zuroku.masao.ai`) を Referer に載せると、X / 一部 CDN /
報道サイトの hotlink protection が **403 / placeholder** を返す。
- `curl` / `fetch(url)` だと 200 が返るので「URL は生きている」と誤認しがちだが、
ブラウザ subresource として読むと壊れる。**初見エージェントが最も機械的に踏む罠**。
- 自動付与時は stderr に `info html: added referrerpolicy="no-referrer" to N external <img>/<iframe>` が出る。
- 既に `referrerpolicy` が指定済みで値が `no-referrer` 以外 (`origin` / `unsafe-url` 等) なら
CLI は **書き換えず warn を出す** (誤設定の hint)。
スコープ外:
- `<a href="https://...">` (ナビゲーションは hotlink 制限の対象外)
- `img/<basename>` (zuroku asset。同一 origin)
- `data:` / `blob:` URI
### R5. local-path leak preflight (v0.1.3+)

@@ -110,2 +148,16 @@

### `--keep-assets` — 本文だけ直して画像はそのまま (v0.1.5+)
```bash
# HTML だけ差し替え、既存の画像は一切触らない。img 引数は不要 (渡しても無視)。
zuroku update my-cool-page ./index.html --keep-assets
```
- 既存画像を **温存** したまま HTML だけ更新する。全置換モードと違い画像の再アップロード不要。
- 「文言だけ直したい」「typo 修正」など本文のみの更新で、画像の渡し忘れによる一括削除事故を防げる。
- asset 欠落 preflight はスキップされる (HTML が参照する `img/*` はサーバ側に温存されている前提)。
- ただし新 HTML が**サーバに無い `img/*` を参照している**場合は CLI が warn を出す (`--keep-assets: HTML references img/ files not present on the server`)。「本文だけ直す」つもりで画像参照名を変えると沈黙して 404 になる事故を防ぐためのもの。warn が出たら参照名を直すか、`--keep-assets` を外して全画像を渡す全置換モードに切り替える。
- thumbnail / OG 画像も従来のまま維持される。
- 画像を **足す / 差し替える / 消す** ときは `--keep-assets` を付けず、全画像を positional で渡す全置換モードを使う。
## 認証

@@ -131,3 +183,3 @@

config は `~/.config/zuroku/config.json` に `0600` で保存される (`XDG_CONFIG_HOME` 尊重)。
`public` は server 予約のため `set` でも reject される。`publish` は flag 未指定時に config を fallback する (info 行 `visibility: <mode> (from config default_visibility)` が出る)。
`default-visibility` に設定できるのは `private` / `curator` のみ。**`public` は `set` で reject される** (公開は per-publish で `--visibility public` を明示する設計で、暗黙のデフォルトにはしない)。`publish` は flag 未指定時に config を fallback する (info 行 `visibility: <mode> (from config default_visibility)` が出る)。

@@ -158,2 +210,3 @@ ## list / delete

| `LOCAL_PATH_LEAK` | HTML 本文に `/Users/...` 等の作者マシン path が混入 (v0.1.3+ で検知) | HTML 側で絶対パスを削除 / 公開 URL に置換 / ファイル名だけ抽象的に言及 |
| 公開ページで外部画像だけ 403 / placeholder | hotlink protection (Referer 検査) | v0.1.5+ は自動付与。**warn 行 `referrerpolicy="..." (recommended: "no-referrer")` が出たら** HTML 側を `no-referrer` に直す。`<a>` には不要 |
| 配信ページで画像 404 | (v0.1.1+ では自動 rewrite される。それ以前 / HTML を直接書き換えていた場合) basename 不一致。HTML の `<img src="img/...">` と asset 引数の filename を再確認 |

@@ -160,0 +213,0 @@ | `UNSUPPORTED_MEDIA 415` | SVG / Content-Type 偽装 | PNG/JPEG/WebP/GIF のみ |

Sorry, the diff of this file is too big to display