@takuhon/cli 0.11.0 → 0.13.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/dist/index.js CHANGED
@@ -1,18 +1,344 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ ADMIN_DIST_DIRNAME,
3
4
  resolveAdminBundleDir
4
- } from "./chunk-JI2AH5Y3.js";
5
+ } from "./chunk-TNTKQL7R.js";
5
6
 
6
7
  // src/index.ts
7
- import { readFileSync as readFileSync11, realpathSync } from "fs";
8
+ import { readFileSync as readFileSync15, realpathSync } from "fs";
8
9
  import { stdin, stdout } from "process";
9
10
  import { createInterface } from "readline/promises";
10
11
  import { fileURLToPath } from "url";
11
12
 
13
+ // src/activity-command.ts
14
+ import { readFileSync as readFileSync2 } from "fs";
15
+ import { dirname as dirname3, join as join3 } from "path";
16
+ import { fetchActivitySnapshot, isEmptySnapshot } from "@takuhon/activity";
17
+
18
+ // src/file-activity-storage.ts
19
+ import { readFileSync } from "fs";
20
+ import { dirname as dirname2, join as join2, resolve } from "path";
21
+ import { isActivitySnapshot } from "@takuhon/core";
22
+
23
+ // src/backup.ts
24
+ import { mkdirSync, renameSync, rmSync, writeFileSync } from "fs";
25
+ import { basename, dirname, join } from "path";
26
+ var BACKUP_DIR_NAME = ".takuhon-backups";
27
+ var BackupError = class extends Error {
28
+ constructor(message, options) {
29
+ super(message, options);
30
+ this.name = "BackupError";
31
+ }
32
+ };
33
+ function compactTimestamp(date, withMillis = false) {
34
+ const iso = date.toISOString();
35
+ const trimmed = withMillis ? iso : iso.replace(/\.\d{3}Z$/, "Z");
36
+ return trimmed.replace(/[-:]/g, "");
37
+ }
38
+ function migrateBackupName(version, date, withMillis = false) {
39
+ return `takuhon-backup-v${version}-${compactTimestamp(date, withMillis)}.json`;
40
+ }
41
+ function preRestoreName(date, withMillis = false) {
42
+ return `pre-restore-${compactTimestamp(date, withMillis)}.json`;
43
+ }
44
+ function preImportName(date, withMillis = false) {
45
+ return `pre-import-${compactTimestamp(date, withMillis)}.json`;
46
+ }
47
+ function preAdminSaveName(date, withMillis = false) {
48
+ return `pre-admin-${compactTimestamp(date, withMillis)}.json`;
49
+ }
50
+ function backupDirFor(targetPath) {
51
+ return join(dirname(targetPath), BACKUP_DIR_NAME);
52
+ }
53
+ function createBackup(params) {
54
+ const dir = backupDirFor(params.targetPath);
55
+ mkdirSync(dir, { recursive: true });
56
+ const primary = join(dir, params.name(false));
57
+ try {
58
+ writeFileSync(primary, params.content, { flag: "wx" });
59
+ return primary;
60
+ } catch (error) {
61
+ if (!isAlreadyExists(error)) throw error;
62
+ }
63
+ const fallback = join(dir, params.name(true));
64
+ try {
65
+ writeFileSync(fallback, params.content, { flag: "wx" });
66
+ return fallback;
67
+ } catch (error) {
68
+ if (isAlreadyExists(error)) {
69
+ throw new BackupError(
70
+ `backup target already exists and could not be disambiguated: ${fallback}`,
71
+ { cause: error }
72
+ );
73
+ }
74
+ throw error;
75
+ }
76
+ }
77
+ function isAlreadyExists(error) {
78
+ return typeof error === "object" && error !== null && error.code === "EEXIST";
79
+ }
80
+ function writeFileAtomic(target, content) {
81
+ const tmp = join(dirname(target), `.${basename(target)}.${process.pid}.tmp`);
82
+ try {
83
+ writeFileSync(tmp, content, "utf8");
84
+ renameSync(tmp, target);
85
+ } catch (error) {
86
+ rmSync(tmp, { force: true });
87
+ throw error;
88
+ }
89
+ }
90
+
91
+ // src/file-activity-storage.ts
92
+ var ACTIVITY_FILENAME = "activity.json";
93
+ function isNotFound(err) {
94
+ return typeof err === "object" && err !== null && err.code === "ENOENT";
95
+ }
96
+ function activityPathFor(profilePath) {
97
+ return join2(dirname2(resolve(profilePath)), ACTIVITY_FILENAME);
98
+ }
99
+ function readSnapshotFile(path) {
100
+ let raw;
101
+ try {
102
+ raw = readFileSync(path, "utf8");
103
+ } catch (err) {
104
+ if (isNotFound(err)) return null;
105
+ throw err;
106
+ }
107
+ let parsed;
108
+ try {
109
+ parsed = JSON.parse(raw);
110
+ } catch {
111
+ return null;
112
+ }
113
+ return isActivitySnapshot(parsed) ? parsed : null;
114
+ }
115
+ function readActivitySnapshotSync(profilePath) {
116
+ return readSnapshotFile(activityPathFor(profilePath));
117
+ }
118
+ var FileActivityStorage = class {
119
+ /** Absolute path of the `activity.json` this storage reads and writes. */
120
+ path;
121
+ constructor(profilePath) {
122
+ this.path = activityPathFor(profilePath);
123
+ }
124
+ // The filesystem work is synchronous, but the ActivityStorage contract is
125
+ // async. Each method runs its body inside `Promise.resolve().then(...)` so a
126
+ // synchronous throw becomes a rejected promise rather than an exception the
127
+ // caller must catch outside `await` (same pattern as FileStorage).
128
+ getActivitySnapshot() {
129
+ return Promise.resolve().then(() => readSnapshotFile(this.path));
130
+ }
131
+ saveActivitySnapshot(snapshot) {
132
+ return Promise.resolve().then(() => {
133
+ writeFileAtomic(this.path, `${JSON.stringify(snapshot, null, 2)}
134
+ `);
135
+ });
136
+ }
137
+ };
138
+
139
+ // src/activity-command.ts
140
+ var DEFAULT_PATH = "takuhon.json";
141
+ var GITHUB_TOKEN_ENV = "TAKUHON_GITHUB_TOKEN";
142
+ var WAKATIME_KEY_ENV = "TAKUHON_WAKATIME_KEY";
143
+ var SYNC_USAGE = `Usage: takuhon activity sync [path]
144
+
145
+ Fetch the GitHub / WakaTime activity configured under settings.activity in a
146
+ takuhon.json and store the result as activity.json beside it \u2014 the sibling
147
+ snapshot the renderer reads, kept outside the canonical profile. With no path,
148
+ syncs ./takuhon.json in the current working directory.
149
+
150
+ Secrets are read from the environment, never from flags or the profile:
151
+ ${GITHUB_TOKEN_ENV} Optional. Raises the GitHub rate limit and unlocks the
152
+ contribution calendar (which is GraphQL/token-only).
153
+ Languages work without it.
154
+ ${WAKATIME_KEY_ENV} Required to read WakaTime coding time.
155
+
156
+ A sync that gathers no data keeps the last-known activity.json \u2014 a good
157
+ snapshot is never overwritten with an empty one.
158
+
159
+ Exit codes: 0 = synced, 1 = no data gathered (last-known snapshot kept),
160
+ 2 = bad arguments / profile missing / unreadable / not JSON / activity not
161
+ configured or not enabled in settings.activity.
162
+ `;
163
+ var SHOW_USAGE = `Usage: takuhon activity show [path]
164
+
165
+ Print the stored activity snapshot (the activity.json beside the profile) as
166
+ JSON. With no path, reads beside ./takuhon.json in the current working
167
+ directory.
168
+
169
+ Exit codes: 0 = printed, 1 = no (or invalid) snapshot stored, 2 = bad
170
+ arguments.
171
+ `;
172
+ function defaultSecrets() {
173
+ return {
174
+ githubToken: process.env[GITHUB_TOKEN_ENV],
175
+ wakatimeKey: process.env[WAKATIME_KEY_ENV]
176
+ };
177
+ }
178
+ function parsePathArg(args, sub) {
179
+ let path;
180
+ for (const arg of args) {
181
+ if (arg.startsWith("-")) {
182
+ return { error: `takuhon: unknown option \`${arg}\` for \`activity ${sub}\`.` };
183
+ }
184
+ if (path !== void 0) {
185
+ return { error: `takuhon: \`activity ${sub}\` takes at most one path argument.` };
186
+ }
187
+ path = arg;
188
+ }
189
+ return { path: path ?? DEFAULT_PATH };
190
+ }
191
+ function activityDisplayPath(profilePath) {
192
+ return join3(dirname3(profilePath), ACTIVITY_FILENAME);
193
+ }
194
+ function readActivityConfig(path) {
195
+ const fail = (code, stderr) => ({
196
+ outcome: { code, stdout: "", stderr }
197
+ });
198
+ let raw;
199
+ try {
200
+ raw = readFileSync2(path, "utf8");
201
+ } catch {
202
+ return fail(
203
+ 2,
204
+ `takuhon: cannot read '${path}'. Pass a path, or run from a directory containing a takuhon.json.
205
+ `
206
+ );
207
+ }
208
+ let data;
209
+ try {
210
+ data = JSON.parse(raw);
211
+ } catch (error) {
212
+ const detail = error instanceof Error ? error.message : String(error);
213
+ return fail(2, `takuhon: '${path}' is not valid JSON: ${detail}
214
+ `);
215
+ }
216
+ const settings = typeof data === "object" && data !== null ? data.settings : void 0;
217
+ const config = settings?.activity;
218
+ if (config === void 0) {
219
+ return fail(
220
+ 2,
221
+ `takuhon: '${path}' has no settings.activity; nothing to sync.
222
+ Opt in by adding settings.activity with "enabled": true and a github/wakatime username.
223
+ `
224
+ );
225
+ }
226
+ if (config.enabled !== true) {
227
+ return fail(
228
+ 2,
229
+ `takuhon: activity is not enabled in '${path}'; nothing to sync.
230
+ Set settings.activity.enabled to true to opt in.
231
+ `
232
+ );
233
+ }
234
+ const hasGithub = config.github?.username !== void 0 && config.github.username !== "";
235
+ const hasWakatime = config.wakatime?.username !== void 0 && config.wakatime.username !== "";
236
+ if (!hasGithub && !hasWakatime) {
237
+ return fail(
238
+ 2,
239
+ `takuhon: settings.activity in '${path}' names no github or wakatime username; nothing to sync.
240
+ `
241
+ );
242
+ }
243
+ return { config };
244
+ }
245
+ function redactSecrets(text, secrets) {
246
+ let out = text;
247
+ for (const secret of [secrets.githubToken, secrets.wakatimeKey]) {
248
+ if (secret !== void 0 && secret !== "") out = out.split(secret).join("***");
249
+ }
250
+ return out;
251
+ }
252
+ async function runActivitySync(args = [], deps = {}) {
253
+ if (args[0] === "--help" || args[0] === "-h") {
254
+ return { code: 0, stdout: SYNC_USAGE, stderr: "" };
255
+ }
256
+ const parsed = parsePathArg(args, "sync");
257
+ if ("error" in parsed) {
258
+ return {
259
+ code: 2,
260
+ stdout: "",
261
+ stderr: `${parsed.error}
262
+ Run \`takuhon activity sync --help\` for usage.
263
+ `
264
+ };
265
+ }
266
+ const read = readActivityConfig(parsed.path);
267
+ if ("outcome" in read) return read.outcome;
268
+ const { config } = read;
269
+ const getSecrets = deps.getSecrets ?? defaultSecrets;
270
+ const secrets = getSecrets();
271
+ const notes = [];
272
+ const githubUser = config.github?.username;
273
+ if (githubUser !== void 0 && githubUser !== "" && config.github?.showContributions !== false && !secrets.githubToken) {
274
+ notes.push(
275
+ `takuhon: ${GITHUB_TOKEN_ENV} is not set; skipping GitHub contributions (languages need no token).
276
+ `
277
+ );
278
+ }
279
+ const wakatimeUser = config.wakatime?.username;
280
+ if (wakatimeUser !== void 0 && wakatimeUser !== "" && config.wakatime?.showCodingTime !== false && !secrets.wakatimeKey) {
281
+ notes.push(`takuhon: ${WAKATIME_KEY_ENV} is not set; skipping WakaTime coding time.
282
+ `);
283
+ }
284
+ const snapshot = await fetchActivitySnapshot(config, secrets, {
285
+ fetch: deps.fetch,
286
+ now: deps.now,
287
+ onError: (source, err) => {
288
+ const detail = err instanceof Error ? err.message : String(err);
289
+ notes.push(`takuhon: ${source} failed: ${redactSecrets(detail, secrets)}
290
+ `);
291
+ }
292
+ });
293
+ const storage = new FileActivityStorage(parsed.path);
294
+ const displayPath = activityDisplayPath(parsed.path);
295
+ const stderr = notes.join("");
296
+ if (isEmptySnapshot(snapshot)) {
297
+ const existing = await storage.getActivitySnapshot();
298
+ const kept = existing !== null ? `keeping the last-known snapshot at '${displayPath}'` : "nothing written";
299
+ return {
300
+ code: 1,
301
+ stdout: "",
302
+ stderr: `${stderr}takuhon: the sync gathered no activity data; ${kept}.
303
+ `
304
+ };
305
+ }
306
+ await storage.saveActivitySnapshot(snapshot);
307
+ const gathered = ["languages", "contributions", "codingTime", "rank"].filter((field) => snapshot[field] !== void 0).join(", ");
308
+ return { code: 0, stdout: `synced activity -> ${displayPath} (${gathered})
309
+ `, stderr };
310
+ }
311
+ async function runActivityShow(args = []) {
312
+ if (args[0] === "--help" || args[0] === "-h") {
313
+ return { code: 0, stdout: SHOW_USAGE, stderr: "" };
314
+ }
315
+ const parsed = parsePathArg(args, "show");
316
+ if ("error" in parsed) {
317
+ return {
318
+ code: 2,
319
+ stdout: "",
320
+ stderr: `${parsed.error}
321
+ Run \`takuhon activity show --help\` for usage.
322
+ `
323
+ };
324
+ }
325
+ const snapshot = await new FileActivityStorage(parsed.path).getActivitySnapshot();
326
+ if (snapshot === null) {
327
+ return {
328
+ code: 1,
329
+ stdout: "",
330
+ stderr: `takuhon: no activity snapshot at '${activityDisplayPath(parsed.path)}'. Run \`takuhon activity sync\` first.
331
+ `
332
+ };
333
+ }
334
+ return { code: 0, stdout: `${JSON.stringify(snapshot, null, 2)}
335
+ `, stderr: "" };
336
+ }
337
+
12
338
  // src/admin-command.ts
13
- import { randomBytes } from "crypto";
14
- import { readFileSync as readFileSync3 } from "fs";
15
- import { extname, join as join2, resolve, sep } from "path";
339
+ import { randomBytes as randomBytes2 } from "crypto";
340
+ import { readFileSync as readFileSync6 } from "fs";
341
+ import { extname as extname2, join as join5, resolve as resolve3, sep as sep2 } from "path";
16
342
  import { serve } from "@hono/node-server";
17
343
  import {
18
344
  adminAssetSecurityHeaders,
@@ -23,24 +349,38 @@ import {
23
349
  import { Hono } from "hono";
24
350
 
25
351
  // src/dev-command.ts
26
- import { readFileSync } from "fs";
352
+ import { readFileSync as readFileSync3 } from "fs";
27
353
  import { createServer } from "http";
28
354
  import { applyPublicPrivacyFilter, normalize, validate } from "@takuhon/core";
29
355
 
30
356
  // src/build-html.ts
31
- import { generateJsonLd } from "@takuhon/core";
357
+ import { generateJsonLd, renderActivitySvg } from "@takuhon/core";
358
+
359
+ // src/html-helpers.ts
32
360
  function escapeHtml(value) {
33
361
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
34
362
  }
35
- function escapeJsonLd(json) {
36
- return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
37
- }
38
363
  function safeUrl(url) {
39
364
  const trimmed = url.trim();
40
365
  const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed)?.[1]?.toLowerCase();
41
366
  if (scheme === void 0) return trimmed;
42
367
  return scheme === "http" || scheme === "https" || scheme === "mailto" ? trimmed : void 0;
43
368
  }
369
+ function dateRange(start, end, isCurrent) {
370
+ const left = start ?? "";
371
+ const right = isCurrent === true || end === null ? "Present" : end ?? "";
372
+ if (left && right) return `${left} \u2013 ${right}`;
373
+ return left || right;
374
+ }
375
+ function nonEmpty(values, separator) {
376
+ const joined = values.filter((v) => typeof v === "string" && v.length > 0).join(separator);
377
+ return joined.length > 0 ? joined : void 0;
378
+ }
379
+
380
+ // src/build-html.ts
381
+ function escapeJsonLd(json) {
382
+ return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
383
+ }
44
384
  var CSS = `:root{--fg:#1a1a1a;--muted:#666;--accent:#0b5fff;--line:#e5e5e5}
45
385
  *{box-sizing:border-box}
46
386
  body{margin:0;color:var(--fg);font:16px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background:#fff}
@@ -64,17 +404,8 @@ ul{padding:0;margin:0;list-style:none}
64
404
  .rec blockquote{margin:0;padding-left:.9rem;border-left:3px solid var(--line)}
65
405
  .rec figcaption{color:var(--muted);font-size:.9rem;margin-top:.3rem}
66
406
  nav.locales{display:flex;gap:.75rem;margin-bottom:1rem;font-size:.9rem}
407
+ .activity svg{max-width:100%;height:auto}
67
408
  footer.powered{max-width:42rem;margin:0 auto;padding:1.5rem 1.25rem;color:var(--muted);font-size:.85rem}`;
68
- function dateRange(start, end, isCurrent) {
69
- const left = start ?? "";
70
- const right = isCurrent === true || end === null ? "Present" : end ?? "";
71
- if (left && right) return `${left} \u2013 ${right}`;
72
- return left || right;
73
- }
74
- function nonEmpty(values, separator) {
75
- const joined = values.filter((v) => typeof v === "string" && v.length > 0).join(separator);
76
- return joined.length > 0 ? joined : void 0;
77
- }
78
409
  function renderEntry(entry) {
79
410
  const href = entry.url ? safeUrl(entry.url) : void 0;
80
411
  const heading = href ? `<a href="${escapeHtml(href)}">${escapeHtml(entry.heading)}</a>` : escapeHtml(entry.heading);
@@ -151,6 +482,12 @@ function renderContact(contact) {
151
482
  if (items.length === 0) return "";
152
483
  return `<section><h2>Contact</h2><ul class="entries">${items.join("")}</ul></section>`;
153
484
  }
485
+ function renderActivity(snapshot) {
486
+ if (!snapshot) return "";
487
+ const svg = renderActivitySvg(snapshot);
488
+ if (svg === "") return "";
489
+ return `<section class="activity"><h2>Activity</h2>${svg}</section>`;
490
+ }
154
491
  function renderJsonLdScript(data) {
155
492
  const payload = JSON.stringify(generateJsonLd(data));
156
493
  return `<script type="application/ld+json">${escapeJsonLd(payload)}</script>`;
@@ -202,6 +539,7 @@ function renderProfileHtml(input) {
202
539
  }))
203
540
  ),
204
541
  renderSkills(d.skills),
542
+ renderActivity(input.activitySnapshot),
205
543
  entryList(
206
544
  "Education",
207
545
  d.education.map((e) => {
@@ -314,13 +652,226 @@ ${footer ? `${footer}
314
652
  }
315
653
 
316
654
  // src/site.ts
317
- import { resolveLocale } from "@takuhon/core";
655
+ import { deriveCv, resolveLocale } from "@takuhon/core";
656
+
657
+ // src/cv-html.ts
658
+ var SECTION_TITLES = {
659
+ experience: "Experience",
660
+ education: "Education",
661
+ skills: "Skills",
662
+ certifications: "Certifications",
663
+ publications: "Publications",
664
+ honors: "Honors & Awards",
665
+ courses: "Courses",
666
+ patents: "Patents",
667
+ languages: "Languages",
668
+ volunteering: "Volunteering",
669
+ memberships: "Memberships"
670
+ };
671
+ var CSS2 = `:root{--fg:#1a1a1a;--muted:#555;--accent:#0b5fff;--line:#d9d9d9}
672
+ *{box-sizing:border-box}
673
+ body{margin:0;background:#f3f4f6;color:var(--fg);font:13px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
674
+ main{background:#fff;width:210mm;min-height:297mm;margin:1.5rem auto;padding:18mm 16mm;box-shadow:0 1px 6px rgba(0,0,0,.15)}
675
+ h1{font-size:1.7rem;margin:0}
676
+ .tagline{font-size:1.05rem;color:var(--muted);margin:.15rem 0 0}
677
+ .contact{color:var(--muted);font-size:.85rem;margin:.4rem 0 0;display:flex;flex-wrap:wrap;gap:.25rem 1rem}
678
+ .contact a{color:var(--accent)}
679
+ .bio{margin:.75rem 0 0}
680
+ header{border-bottom:2px solid var(--fg);padding-bottom:.6rem;margin-bottom:.4rem}
681
+ section{margin-top:1.1rem}
682
+ h2{font-size:.95rem;text-transform:uppercase;letter-spacing:.05em;color:var(--accent);border-bottom:1px solid var(--line);margin:0 0 .5rem;padding-bottom:.15rem}
683
+ ul{padding:0;margin:0;list-style:none}
684
+ .entry{margin:0 0 .7rem;break-inside:avoid}
685
+ .entry .row{display:flex;justify-content:space-between;gap:1rem;align-items:baseline}
686
+ .entry h3{font-size:.95rem;margin:0}
687
+ .entry .dates{color:var(--muted);font-size:.8rem;white-space:nowrap}
688
+ .entry .sub{color:var(--muted);margin:.05rem 0}
689
+ .entry p{margin:.2rem 0 0}
690
+ .entry a{color:var(--accent)}
691
+ .chips{display:flex;flex-wrap:wrap;gap:.3rem}
692
+ .chips li{border:1px solid var(--line);border-radius:1rem;padding:.05rem .55rem;font-size:.8rem}
693
+ @media print{
694
+ body{background:#fff}
695
+ main{width:auto;min-height:0;margin:0;padding:0;box-shadow:none}
696
+ a{color:var(--fg)}
697
+ }
698
+ @page{size:A4;margin:14mm}`;
699
+ function renderEntry2(entry) {
700
+ const href = entry.url ? safeUrl(entry.url) : void 0;
701
+ const title = href ? `<a href="${escapeHtml(href)}">${escapeHtml(entry.heading)}</a>` : escapeHtml(entry.heading);
702
+ const dates = entry.dates ? `<span class="dates">${escapeHtml(entry.dates)}</span>` : "";
703
+ const parts = [`<div class="row"><h3>${title}</h3>${dates}</div>`];
704
+ if (entry.sub) parts.push(`<p class="sub">${escapeHtml(entry.sub)}</p>`);
705
+ if (entry.body) parts.push(`<p>${escapeHtml(entry.body)}</p>`);
706
+ return `<li class="entry">${parts.join("")}</li>`;
707
+ }
708
+ function entryListSection(title, entries) {
709
+ return `<section><h2>${escapeHtml(title)}</h2><ul>${entries.map(renderEntry2).join("")}</ul></section>`;
710
+ }
711
+ function chipSection(title, labels) {
712
+ const chips = labels.map((l) => `<li>${escapeHtml(l)}</li>`).join("");
713
+ return `<section><h2>${escapeHtml(title)}</h2><ul class="chips">${chips}</ul></section>`;
714
+ }
715
+ function languageLabel(l) {
716
+ return `${l.displayName ?? l.language} \u2014 ${l.proficiency}`;
717
+ }
718
+ function renderSection(section) {
719
+ const title = SECTION_TITLES[section.kind];
720
+ switch (section.kind) {
721
+ case "experience":
722
+ return entryListSection(
723
+ title,
724
+ section.entries.map((c) => ({
725
+ heading: c.role,
726
+ sub: c.organization,
727
+ dates: dateRange(c.startDate, c.endDate, c.isCurrent),
728
+ body: c.description,
729
+ url: c.url
730
+ }))
731
+ );
732
+ case "education":
733
+ return entryListSection(
734
+ title,
735
+ section.entries.map((e) => {
736
+ const degree = nonEmpty([e.degree, e.fieldOfStudy], ", ");
737
+ return {
738
+ heading: degree ?? e.institution,
739
+ sub: degree ? e.institution : void 0,
740
+ dates: dateRange(e.startDate, e.endDate, e.isCurrent),
741
+ body: e.description,
742
+ url: e.url
743
+ };
744
+ })
745
+ );
746
+ case "skills":
747
+ return chipSection(
748
+ title,
749
+ section.entries.map((s) => s.label)
750
+ );
751
+ case "languages":
752
+ return chipSection(title, section.entries.map(languageLabel));
753
+ case "certifications":
754
+ return entryListSection(
755
+ title,
756
+ section.entries.map((c) => ({
757
+ heading: c.title,
758
+ sub: c.issuingOrganization,
759
+ dates: dateRange(c.issueDate, c.expirationDate),
760
+ url: c.url
761
+ }))
762
+ );
763
+ case "publications":
764
+ return entryListSection(
765
+ title,
766
+ section.entries.map((x) => ({
767
+ heading: x.title,
768
+ sub: nonEmpty([x.publisher, x.coAuthors?.join(", ")], " \xB7 "),
769
+ dates: dateRange(x.date),
770
+ body: x.description,
771
+ url: x.url ?? (x.doi ? `https://doi.org/${x.doi}` : void 0)
772
+ }))
773
+ );
774
+ case "honors":
775
+ return entryListSection(
776
+ title,
777
+ section.entries.map((x) => ({
778
+ heading: x.title,
779
+ sub: x.issuer,
780
+ dates: dateRange(x.date),
781
+ body: x.description,
782
+ url: x.url
783
+ }))
784
+ );
785
+ case "courses":
786
+ return entryListSection(
787
+ title,
788
+ section.entries.map((x) => ({
789
+ heading: x.title,
790
+ sub: x.provider,
791
+ dates: dateRange(x.completionDate),
792
+ body: x.description,
793
+ url: x.certificateUrl
794
+ }))
795
+ );
796
+ case "patents":
797
+ return entryListSection(
798
+ title,
799
+ section.entries.map((x) => ({
800
+ heading: x.title,
801
+ sub: nonEmpty([x.patentNumber, x.office, x.status], " \xB7 "),
802
+ dates: dateRange(x.filingDate ?? x.grantDate),
803
+ body: x.description,
804
+ url: x.url
805
+ }))
806
+ );
807
+ case "volunteering":
808
+ return entryListSection(
809
+ title,
810
+ section.entries.map((x) => ({
811
+ heading: x.role,
812
+ sub: nonEmpty([x.organization, x.cause], " \xB7 "),
813
+ dates: dateRange(x.startDate, x.endDate, x.isCurrent),
814
+ body: x.description,
815
+ url: x.url
816
+ }))
817
+ );
818
+ case "memberships":
819
+ return entryListSection(
820
+ title,
821
+ section.entries.map((x) => ({
822
+ heading: x.role ?? x.organization,
823
+ sub: x.role ? x.organization : void 0,
824
+ dates: dateRange(x.startDate, x.endDate, x.isCurrent),
825
+ body: x.description,
826
+ url: x.url
827
+ }))
828
+ );
829
+ }
830
+ }
831
+ function renderHeader2(cv) {
832
+ const h = cv.header;
833
+ const parts = [`<h1>${escapeHtml(h.displayName)}</h1>`];
834
+ if (h.tagline) parts.push(`<p class="tagline">${escapeHtml(h.tagline)}</p>`);
835
+ const contact = [];
836
+ if (h.location) contact.push(`<span>${escapeHtml(h.location)}</span>`);
837
+ if (h.email) {
838
+ contact.push(`<a href="mailto:${escapeHtml(h.email)}">${escapeHtml(h.email)}</a>`);
839
+ }
840
+ const formHref = h.formUrl ? safeUrl(h.formUrl) : void 0;
841
+ if (formHref) contact.push(`<a href="${escapeHtml(formHref)}">Contact</a>`);
842
+ if (contact.length > 0) parts.push(`<div class="contact">${contact.join("")}</div>`);
843
+ if (h.bio) parts.push(`<p class="bio">${escapeHtml(h.bio)}</p>`);
844
+ return `<header>${parts.join("")}</header>`;
845
+ }
846
+ function renderCvHtml(cv) {
847
+ const title = `${cv.header.displayName} \u2014 CV`;
848
+ const body = [renderHeader2(cv), ...cv.sections.map(renderSection)].join("\n");
849
+ return `<!DOCTYPE html>
850
+ <html lang="${escapeHtml(cv.resolvedLocale)}">
851
+ <head>
852
+ <meta charset="utf-8">
853
+ <meta name="viewport" content="width=device-width, initial-scale=1">
854
+ <title>${escapeHtml(title)}</title>
855
+ <style>${CSS2}</style>
856
+ </head>
857
+ <body>
858
+ <main>
859
+ ${body}
860
+ </main>
861
+ </body>
862
+ </html>
863
+ `;
864
+ }
865
+
866
+ // src/site.ts
318
867
  function generateSite(profile, options = {}) {
319
868
  const { baseUrl } = options;
320
869
  const defaultLocale = profile.settings.defaultLocale;
321
870
  const locales = [.../* @__PURE__ */ new Set([defaultLocale, ...profile.settings.availableLocales])];
322
871
  const jsonLd = profile.settings.enableJsonLd !== false;
323
- return locales.map((locale) => {
872
+ const activitySnapshot = profile.settings.activity?.enabled === true ? options.activitySnapshot ?? void 0 : void 0;
873
+ const pages = [];
874
+ for (const locale of locales) {
324
875
  const localized = resolveLocale(profile, locale);
325
876
  const isDefault = locale === defaultLocale;
326
877
  const localeNav = locales.map((to) => ({
@@ -330,13 +881,28 @@ function generateSite(profile, options = {}) {
330
881
  }));
331
882
  const canonicalUrl = baseUrl ? absoluteUrl(baseUrl, locale, defaultLocale) : void 0;
332
883
  const alternates = baseUrl ? buildAlternates(baseUrl, locales, defaultLocale) : [];
333
- const html = renderProfileHtml({ localized, canonicalUrl, alternates, localeNav, jsonLd });
334
- return {
884
+ const html = renderProfileHtml({
885
+ localized,
886
+ canonicalUrl,
887
+ alternates,
888
+ localeNav,
889
+ jsonLd,
890
+ activitySnapshot
891
+ });
892
+ pages.push({
335
893
  route: isDefault ? "/" : `/${locale}/`,
336
894
  file: isDefault ? "index.html" : `${locale}/index.html`,
337
895
  html
338
- };
339
- });
896
+ });
897
+ if (options.cv === true) {
898
+ pages.push({
899
+ route: isDefault ? "/cv/" : `/${locale}/cv/`,
900
+ file: isDefault ? "cv.html" : `${locale}/cv.html`,
901
+ html: renderCvHtml(deriveCv(localized))
902
+ });
903
+ }
904
+ }
905
+ return pages;
340
906
  }
341
907
  function absoluteUrl(baseUrl, locale, defaultLocale) {
342
908
  return locale === defaultLocale ? `${baseUrl}/` : `${baseUrl}/${locale}/`;
@@ -360,14 +926,14 @@ function localeHref(from, to, defaultLocale) {
360
926
  }
361
927
 
362
928
  // src/dev-command.ts
363
- var DEFAULT_PATH = "takuhon.json";
929
+ var DEFAULT_PATH2 = "takuhon.json";
364
930
  var DEFAULT_PORT = 4321;
365
931
  var USAGE = `Usage: takuhon dev [path] [--port <n>] [--base-url <url>]
366
932
 
367
- Serve a takuhon.json as a local static preview (one page per locale) \u2014 the same
368
- surface \`takuhon build\` produces. With no path, serves ./takuhon.json. The file
369
- is re-read and re-rendered on every request, so edit it and reload the browser
370
- to see changes. Stop with Ctrl-C.
933
+ Serve a takuhon.json as a local static preview (one page per locale, plus a
934
+ print-ready CV page at /cv) \u2014 the same surface \`takuhon build\` produces. With
935
+ no path, serves ./takuhon.json. The file is re-read and re-rendered on every
936
+ request, so edit it and reload the browser to see changes. Stop with Ctrl-C.
371
937
 
372
938
  Options:
373
939
  --port <n> Port to listen on (default: ${DEFAULT_PORT}).
@@ -383,7 +949,7 @@ unreadable / port in use.
383
949
  function loadSiteState(path, baseUrl) {
384
950
  let raw;
385
951
  try {
386
- raw = readFileSync(path, "utf8");
952
+ raw = readFileSync3(path, "utf8");
387
953
  } catch {
388
954
  return { ok: false, status: 500, message: `cannot read '${path}'.` };
389
955
  }
@@ -405,7 +971,10 @@ ${lines.join("\n")}`
405
971
  };
406
972
  }
407
973
  const filtered = applyPublicPrivacyFilter(normalize(result.data));
408
- const pages = new Map(generateSite(filtered, { baseUrl }).map((p) => [p.route, p.html]));
974
+ const activitySnapshot = filtered.settings.activity?.enabled === true ? readActivitySnapshotSync(path) : null;
975
+ const pages = new Map(
976
+ generateSite(filtered, { baseUrl, activitySnapshot, cv: true }).map((p) => [p.route, p.html])
977
+ );
409
978
  return { ok: true, pages };
410
979
  }
411
980
  function resolveRoute(urlPath) {
@@ -470,7 +1039,7 @@ Run \`takuhon dev --help\` for usage.
470
1039
  return 2;
471
1040
  }
472
1041
  try {
473
- readFileSync(parsed.path, "utf8");
1042
+ readFileSync3(parsed.path, "utf8");
474
1043
  } catch {
475
1044
  err(
476
1045
  `takuhon: cannot read '${parsed.path}'. Pass a path, or run from a directory containing a takuhon.json.
@@ -479,14 +1048,14 @@ Run \`takuhon dev --help\` for usage.
479
1048
  return 2;
480
1049
  }
481
1050
  const server = createDevServer({ path: parsed.path, baseUrl: parsed.baseUrl });
482
- return await new Promise((resolve4) => {
1051
+ return await new Promise((resolve7) => {
483
1052
  let closing = false;
484
1053
  const shutdown = () => {
485
1054
  if (closing) return;
486
1055
  closing = true;
487
1056
  process.removeListener("SIGINT", shutdown);
488
1057
  process.removeListener("SIGTERM", shutdown);
489
- server.close(() => resolve4(0));
1058
+ server.close(() => resolve7(0));
490
1059
  server.closeAllConnections();
491
1060
  };
492
1061
  server.once("error", (error) => {
@@ -497,7 +1066,7 @@ Run \`takuhon dev --help\` for usage.
497
1066
  err(`takuhon: ${error.message}
498
1067
  `);
499
1068
  }
500
- resolve4(2);
1069
+ resolve7(2);
501
1070
  });
502
1071
  server.listen(parsed.port, "127.0.0.1", () => {
503
1072
  out(
@@ -566,7 +1135,7 @@ function parseArgs(args) {
566
1135
  return { error: "takuhon: `--base-url` must be an absolute http(s) URL." };
567
1136
  }
568
1137
  return {
569
- path: path ?? DEFAULT_PATH,
1138
+ path: path ?? DEFAULT_PATH2,
570
1139
  port,
571
1140
  // Drop any trailing slash so URL joins are predictable.
572
1141
  baseUrl: baseUrl?.replace(/\/+$/, "")
@@ -629,91 +1198,177 @@ function renderNotFoundPage(route, routes) {
629
1198
  );
630
1199
  }
631
1200
 
632
- // src/file-storage.ts
633
- import { createHash } from "crypto";
634
- import { readFileSync as readFileSync2, rmSync as rmSync2 } from "fs";
635
- import { ConflictError, NotFoundError } from "@takuhon/core";
636
-
637
- // src/backup.ts
638
- import { mkdirSync, renameSync, rmSync, writeFileSync } from "fs";
639
- import { basename, dirname, join } from "path";
640
- var BACKUP_DIR_NAME = ".takuhon-backups";
641
- var BackupError = class extends Error {
642
- constructor(message, options) {
643
- super(message, options);
644
- this.name = "BackupError";
645
- }
646
- };
647
- function compactTimestamp(date, withMillis = false) {
648
- const iso = date.toISOString();
649
- const trimmed = withMillis ? iso : iso.replace(/\.\d{3}Z$/, "Z");
650
- return trimmed.replace(/[-:]/g, "");
651
- }
652
- function migrateBackupName(version, date, withMillis = false) {
653
- return `takuhon-backup-v${version}-${compactTimestamp(date, withMillis)}.json`;
654
- }
655
- function preRestoreName(date, withMillis = false) {
656
- return `pre-restore-${compactTimestamp(date, withMillis)}.json`;
657
- }
658
- function preImportName(date, withMillis = false) {
659
- return `pre-import-${compactTimestamp(date, withMillis)}.json`;
660
- }
661
- function preAdminSaveName(date, withMillis = false) {
662
- return `pre-admin-${compactTimestamp(date, withMillis)}.json`;
1201
+ // src/file-asset-storage.ts
1202
+ import { randomBytes } from "crypto";
1203
+ import {
1204
+ existsSync,
1205
+ mkdirSync as mkdirSync2,
1206
+ readdirSync,
1207
+ readFileSync as readFileSync4,
1208
+ renameSync as renameSync2,
1209
+ rmSync as rmSync2,
1210
+ statSync,
1211
+ writeFileSync as writeFileSync2
1212
+ } from "fs";
1213
+ import { basename as basename2, dirname as dirname4, extname, join as join4, resolve as resolve2, sep } from "path";
1214
+ import {
1215
+ ACCEPTED_IMAGE_MIME_TYPES,
1216
+ detectImageMime,
1217
+ IMAGE_EXTENSIONS,
1218
+ NotFoundError,
1219
+ readImageInfo
1220
+ } from "@takuhon/core";
1221
+ var ASSET_DIRNAME = "assets";
1222
+ var ASSET_KEY_PREFIX = `${ASSET_DIRNAME}/`;
1223
+ var DEFAULT_EXTENSION = "bin";
1224
+ var MIME_BY_EXTENSION = new Map(
1225
+ Object.entries(IMAGE_EXTENSIONS).map(([mime, ext]) => [ext, mime])
1226
+ );
1227
+ function asAcceptedMime(value) {
1228
+ if (value === void 0) return null;
1229
+ return ACCEPTED_IMAGE_MIME_TYPES.includes(value) ? value : null;
1230
+ }
1231
+ function contentTypeForFile(name) {
1232
+ const ext = extname(name).slice(1).toLowerCase();
1233
+ return MIME_BY_EXTENSION.get(ext) ?? "application/octet-stream";
1234
+ }
1235
+ function timestampSeconds() {
1236
+ return Math.floor(Date.now() / 1e3);
1237
+ }
1238
+ function shortHash() {
1239
+ return randomBytes(2).toString("hex");
663
1240
  }
664
- function backupDirFor(targetPath) {
665
- return join(dirname(targetPath), BACKUP_DIR_NAME);
1241
+ function isNotFound2(err) {
1242
+ return typeof err === "object" && err !== null && err.code === "ENOENT";
666
1243
  }
667
- function createBackup(params) {
668
- const dir = backupDirFor(params.targetPath);
669
- mkdirSync(dir, { recursive: true });
670
- const primary = join(dir, params.name(false));
671
- try {
672
- writeFileSync(primary, params.content, { flag: "wx" });
673
- return primary;
674
- } catch (error) {
675
- if (!isAlreadyExists(error)) throw error;
676
- }
677
- const fallback = join(dir, params.name(true));
1244
+ function writeFileAtomicBinary(target, bytes) {
1245
+ const tmp = join4(dirname4(target), `.${basename2(target)}.${String(process.pid)}.tmp`);
678
1246
  try {
679
- writeFileSync(fallback, params.content, { flag: "wx" });
680
- return fallback;
1247
+ writeFileSync2(tmp, bytes);
1248
+ renameSync2(tmp, target);
681
1249
  } catch (error) {
682
- if (isAlreadyExists(error)) {
683
- throw new BackupError(
684
- `backup target already exists and could not be disambiguated: ${fallback}`,
685
- { cause: error }
686
- );
687
- }
1250
+ rmSync2(tmp, { force: true });
688
1251
  throw error;
689
1252
  }
690
1253
  }
691
- function isAlreadyExists(error) {
692
- return typeof error === "object" && error !== null && error.code === "EEXIST";
693
- }
694
- function writeFileAtomic(target, content) {
695
- const tmp = join(dirname(target), `.${basename(target)}.${process.pid}.tmp`);
696
- try {
697
- writeFileSync(tmp, content, "utf8");
698
- renameSync(tmp, target);
699
- } catch (error) {
700
- rmSync(tmp, { force: true });
701
- throw error;
1254
+ var FileTakuhonAssetStorage = class {
1255
+ profilePath;
1256
+ publicBaseUrl;
1257
+ constructor(profilePath, options = {}) {
1258
+ this.profilePath = profilePath;
1259
+ this.publicBaseUrl = options.publicBaseUrl ?? "";
1260
+ }
1261
+ // Genuinely async (awaits the Blob), so no `Promise.resolve().then()` wrap is
1262
+ // needed here — unlike the synchronous methods below.
1263
+ async putAsset(file, options) {
1264
+ const bytes = new Uint8Array(await file.arrayBuffer());
1265
+ const mime = asAcceptedMime(options?.contentType) ?? detectImageMime(bytes);
1266
+ const ext = mime !== null ? IMAGE_EXTENSIONS[mime] : DEFAULT_EXTENSION;
1267
+ const contentType2 = mime ?? options?.contentType ?? file.type ?? "application/octet-stream";
1268
+ const key = `${ASSET_KEY_PREFIX}${String(timestampSeconds())}-${shortHash()}.${ext}`;
1269
+ const full = join4(this.projectDir, key);
1270
+ mkdirSync2(dirname4(full), { recursive: true });
1271
+ writeFileAtomicBinary(full, bytes);
1272
+ const info = mime !== null ? readImageInfo(bytes, mime) : null;
1273
+ const url = `/${key}`;
1274
+ return {
1275
+ id: key,
1276
+ url,
1277
+ publicUrl: this.absoluteUrl(url),
1278
+ mimeType: contentType2,
1279
+ size: bytes.length,
1280
+ width: info?.width,
1281
+ height: info?.height,
1282
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1283
+ };
702
1284
  }
703
- }
1285
+ getPublicUrl(assetId) {
1286
+ return Promise.resolve().then(() => {
1287
+ const full = this.resolveKey(assetId);
1288
+ if (full === null || !existsSync(full)) {
1289
+ throw new NotFoundError(`No asset is stored for "${assetId}".`);
1290
+ }
1291
+ return this.absoluteUrl(`/${assetId}`);
1292
+ });
1293
+ }
1294
+ deleteAsset(assetId) {
1295
+ return Promise.resolve().then(() => {
1296
+ const full = this.resolveKey(assetId);
1297
+ if (full !== null) rmSync2(full, { force: true });
1298
+ });
1299
+ }
1300
+ listAssets() {
1301
+ return Promise.resolve().then(() => {
1302
+ const root = join4(this.projectDir, ASSET_DIRNAME);
1303
+ let names;
1304
+ try {
1305
+ names = readdirSync(root);
1306
+ } catch (err) {
1307
+ if (isNotFound2(err)) return [];
1308
+ throw err;
1309
+ }
1310
+ return names.filter((name) => statSync(join4(root, name)).isFile()).sort().map((name) => {
1311
+ const key = `${ASSET_KEY_PREFIX}${name}`;
1312
+ const url = `/${key}`;
1313
+ return {
1314
+ id: key,
1315
+ url,
1316
+ publicUrl: this.absoluteUrl(url),
1317
+ mimeType: contentTypeForFile(name),
1318
+ size: statSync(join4(root, name)).size,
1319
+ createdAt: statSync(join4(root, name)).mtime.toISOString()
1320
+ };
1321
+ });
1322
+ });
1323
+ }
1324
+ /**
1325
+ * Local-server helper (not part of {@link TakuhonAssetStorage}): read an asset
1326
+ * for static delivery, or `null` when it is absent or the key escapes the
1327
+ * `assets/` directory. The traversal guard lives here so the delivery route
1328
+ * and the persistence methods share one notion of "where assets live".
1329
+ */
1330
+ readForServing(key) {
1331
+ const full = this.resolveKey(key);
1332
+ if (full === null) return null;
1333
+ let bytes;
1334
+ try {
1335
+ bytes = readFileSync4(full);
1336
+ } catch (err) {
1337
+ if (isNotFound2(err)) return null;
1338
+ throw err;
1339
+ }
1340
+ return { bytes, contentType: contentTypeForFile(full) };
1341
+ }
1342
+ get projectDir() {
1343
+ return dirname4(resolve2(this.profilePath));
1344
+ }
1345
+ /** Resolve an `assets/...` key to an absolute path, or null if out of bounds. */
1346
+ resolveKey(key) {
1347
+ const root = resolve2(this.projectDir, ASSET_DIRNAME);
1348
+ const full = resolve2(this.projectDir, key);
1349
+ if (full !== root && !full.startsWith(root + sep)) return null;
1350
+ return full;
1351
+ }
1352
+ absoluteUrl(path) {
1353
+ return this.publicBaseUrl !== "" ? `${this.publicBaseUrl}${path}` : path;
1354
+ }
1355
+ };
704
1356
 
705
1357
  // src/file-storage.ts
1358
+ import { createHash } from "crypto";
1359
+ import { readFileSync as readFileSync5, rmSync as rmSync3 } from "fs";
1360
+ import { ConflictError, NotFoundError as NotFoundError2 } from "@takuhon/core";
706
1361
  function sha256Hex(content) {
707
1362
  return createHash("sha256").update(content, "utf8").digest("hex");
708
1363
  }
709
- function isNotFound(err) {
1364
+ function isNotFound3(err) {
710
1365
  return typeof err === "object" && err !== null && err.code === "ENOENT";
711
1366
  }
712
1367
  function readMaybe(path) {
713
1368
  try {
714
- return readFileSync2(path, "utf8");
1369
+ return readFileSync5(path, "utf8");
715
1370
  } catch (err) {
716
- if (isNotFound(err)) return void 0;
1371
+ if (isNotFound3(err)) return void 0;
717
1372
  throw err;
718
1373
  }
719
1374
  }
@@ -733,7 +1388,7 @@ var FileStorage = class {
733
1388
  return Promise.resolve().then(() => {
734
1389
  const raw = readMaybe(this.path);
735
1390
  if (raw === void 0) {
736
- throw new NotFoundError("No profile is stored yet.");
1391
+ throw new NotFoundError2("No profile is stored yet.");
737
1392
  }
738
1393
  let data;
739
1394
  try {
@@ -780,13 +1435,13 @@ var FileStorage = class {
780
1435
  content: current,
781
1436
  name: (withMillis) => preAdminSaveName(stamp, withMillis)
782
1437
  });
783
- rmSync2(this.path, { force: true });
1438
+ rmSync3(this.path, { force: true });
784
1439
  });
785
1440
  }
786
1441
  };
787
1442
 
788
1443
  // src/admin-command.ts
789
- var DEFAULT_PATH2 = "takuhon.json";
1444
+ var DEFAULT_PATH3 = "takuhon.json";
790
1445
  var DEFAULT_PORT2 = 4322;
791
1446
  var USAGE2 = `Usage: takuhon admin [path] [--port <n>] [--base-url <url>]
792
1447
 
@@ -813,9 +1468,9 @@ var CONTENT_TYPES = {
813
1468
  ".map": "application/json; charset=utf-8"
814
1469
  };
815
1470
  function contentTypeFor(file) {
816
- return CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream";
1471
+ return CONTENT_TYPES[extname2(file).toLowerCase()] ?? "application/octet-stream";
817
1472
  }
818
- function isNotFound2(err) {
1473
+ function isNotFound4(err) {
819
1474
  return typeof err === "object" && err !== null && err.code === "ENOENT";
820
1475
  }
821
1476
  function localAdminHeaders() {
@@ -828,7 +1483,15 @@ function localAdminHeaders() {
828
1483
  return headers;
829
1484
  }
830
1485
  var plain = (status, body) => new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
831
- function serveAdminBundle(bundleDir, method, pathname) {
1486
+ function escapeHtmlAttr(value) {
1487
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1488
+ }
1489
+ function injectLocalToken(html, token) {
1490
+ const meta = `<meta name="takuhon-local-token" content="${escapeHtmlAttr(token)}" />`;
1491
+ if (html.includes("</head>")) return html.replace("</head>", `${meta}</head>`);
1492
+ return meta + html;
1493
+ }
1494
+ function serveAdminBundle(bundleDir, method, pathname, token) {
832
1495
  if (method !== "GET" && method !== "HEAD") return plain(405, "Method Not Allowed\n");
833
1496
  const rest = pathname.slice("/admin".length);
834
1497
  let rel;
@@ -838,34 +1501,57 @@ function serveAdminBundle(bundleDir, method, pathname) {
838
1501
  rel = "";
839
1502
  }
840
1503
  if (rel === "") rel = "index.html";
841
- const root = resolve(bundleDir);
842
- let full = resolve(root, rel);
843
- if (full !== root && !full.startsWith(root + sep)) return plain(403, "Forbidden\n");
1504
+ const root = resolve3(bundleDir);
1505
+ let full = resolve3(root, rel);
1506
+ if (full !== root && !full.startsWith(root + sep2)) return plain(403, "Forbidden\n");
844
1507
  let body;
845
1508
  try {
846
- body = readFileSync3(full);
1509
+ body = readFileSync6(full);
847
1510
  } catch (err) {
848
- if (!isNotFound2(err)) throw err;
849
- if (extname(rel) !== "") return plain(404, "Not Found\n");
850
- full = join2(root, "index.html");
1511
+ if (!isNotFound4(err)) throw err;
1512
+ if (extname2(rel) !== "") return plain(404, "Not Found\n");
1513
+ full = join5(root, "index.html");
851
1514
  try {
852
- body = readFileSync3(full);
1515
+ body = readFileSync6(full);
853
1516
  } catch {
854
1517
  return plain(404, "Not Found\n");
855
1518
  }
856
1519
  }
1520
+ const contentType2 = contentTypeFor(full);
857
1521
  const headers = new Headers(localAdminHeaders());
858
- headers.set("content-type", contentTypeFor(full));
1522
+ headers.set("content-type", contentType2);
1523
+ if (contentType2.startsWith("text/html")) {
1524
+ const injected = injectLocalToken(body.toString("utf8"), token);
1525
+ return new Response(method === "HEAD" ? null : injected, { status: 200, headers });
1526
+ }
859
1527
  return new Response(method === "HEAD" ? null : body, { status: 200, headers });
860
1528
  }
1529
+ function serveLocalAsset(assetStorage, method, pathname) {
1530
+ if (method !== "GET" && method !== "HEAD") return plain(405, "Method Not Allowed\n");
1531
+ let key;
1532
+ try {
1533
+ key = decodeURIComponent(pathname.slice(1));
1534
+ } catch {
1535
+ return plain(400, "Bad Request\n");
1536
+ }
1537
+ const asset = assetStorage.readForServing(key);
1538
+ if (asset === null) return plain(404, "Not Found\n");
1539
+ const headers = new Headers();
1540
+ headers.set("content-type", asset.contentType);
1541
+ headers.set("x-content-type-options", "nosniff");
1542
+ headers.set("cache-control", "public, max-age=31536000, immutable");
1543
+ return new Response(method === "HEAD" ? null : asset.bytes, { status: 200, headers });
1544
+ }
861
1545
  function createAdminApp(opts) {
862
1546
  const bundleDir = opts.bundleDir ?? resolveAdminBundleDir();
863
1547
  const storage = new FileStorage(opts.path);
1548
+ const assetStorage = new FileTakuhonAssetStorage(opts.path, { publicBaseUrl: opts.assetBaseUrl });
864
1549
  const app = new Hono();
865
1550
  app.route(
866
1551
  "/api/admin",
867
1552
  createAdminApiApp({
868
1553
  storage,
1554
+ assetStorage,
869
1555
  getAdminToken: () => opts.token,
870
1556
  // Loopback, same-origin SPA: no Origin allowlist needed (empty = skip).
871
1557
  getAdminOrigins: () => [],
@@ -876,7 +1562,10 @@ function createAdminApp(opts) {
876
1562
  app.all("*", (c) => {
877
1563
  const { method, path } = c.req;
878
1564
  if (path === "/admin" || path.startsWith("/admin/")) {
879
- return serveAdminBundle(bundleDir, method, path);
1565
+ return serveAdminBundle(bundleDir, method, path, opts.token);
1566
+ }
1567
+ if (path.startsWith("/assets/")) {
1568
+ return serveLocalAsset(assetStorage, method, path);
880
1569
  }
881
1570
  const state = loadSiteState(opts.path, opts.baseUrl);
882
1571
  const res = handleRequest(method, path, state);
@@ -935,7 +1624,7 @@ function parseArgs2(args) {
935
1624
  if (baseUrl !== void 0 && !isHttpUrl2(baseUrl)) {
936
1625
  return { error: "takuhon: `--base-url` must be an absolute http(s) URL." };
937
1626
  }
938
- return { path: path ?? DEFAULT_PATH2, port, baseUrl: baseUrl?.replace(/\/+$/, "") };
1627
+ return { path: path ?? DEFAULT_PATH3, port, baseUrl: baseUrl?.replace(/\/+$/, "") };
939
1628
  }
940
1629
  function isHttpUrl2(value) {
941
1630
  try {
@@ -959,8 +1648,13 @@ Run \`takuhon admin --help\` for usage.
959
1648
  `);
960
1649
  return 2;
961
1650
  }
962
- const token = deps.token ?? randomBytes(32).toString("base64url");
963
- const app = createAdminApp({ path: parsed.path, token, baseUrl: parsed.baseUrl });
1651
+ const token = deps.token ?? randomBytes2(32).toString("base64url");
1652
+ const app = createAdminApp({
1653
+ path: parsed.path,
1654
+ token,
1655
+ baseUrl: parsed.baseUrl,
1656
+ assetBaseUrl: `http://127.0.0.1:${String(parsed.port)}`
1657
+ });
964
1658
  return await new Promise((resolvePromise) => {
965
1659
  let closing = false;
966
1660
  function shutdown() {
@@ -997,12 +1691,12 @@ Run \`takuhon admin --help\` for usage.
997
1691
  }
998
1692
 
999
1693
  // src/build-command.ts
1000
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync4 } from "fs";
1001
- import { dirname as dirname2, join as join3 } from "path";
1694
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync7 } from "fs";
1695
+ import { dirname as dirname5, join as join6 } from "path";
1002
1696
  import { applyPublicPrivacyFilter as applyPublicPrivacyFilter2, normalize as normalize2, validate as validate2 } from "@takuhon/core";
1003
- var DEFAULT_PATH3 = "takuhon.json";
1697
+ var DEFAULT_PATH4 = "takuhon.json";
1004
1698
  var DEFAULT_OUTPUT = "dist";
1005
- var USAGE3 = `Usage: takuhon build [path] [--output <dir>] [--base-url <url>]
1699
+ var USAGE3 = `Usage: takuhon build [path] [--output <dir>] [--base-url <url>] [--cv]
1006
1700
 
1007
1701
  Render a takuhon.json into a static site (one HTML page per locale, with
1008
1702
  build-time Schema.org JSON-LD). With no path, builds ./takuhon.json.
@@ -1013,6 +1707,9 @@ Options:
1013
1707
  to <dir>/<locale>/index.html.
1014
1708
  --base-url <url> Site origin (e.g. https://me.example). Enables absolute
1015
1709
  canonical and hreflang links; without it those are omitted.
1710
+ --cv Also emit a print-ready CV/r\xE9sum\xE9 page per locale
1711
+ (<dir>/cv.html and <dir>/<locale>/cv.html). Open it and use
1712
+ the browser's "Save as PDF" to produce a r\xE9sum\xE9 PDF.
1016
1713
 
1017
1714
  The public privacy filter is applied (meta.privacy is honoured). Asset URLs are
1018
1715
  referenced as-is and are not copied. The output directory is written into, not
@@ -1041,8 +1738,13 @@ function parseArgs3(args) {
1041
1738
  let path;
1042
1739
  let output;
1043
1740
  let baseUrl;
1741
+ let cv = false;
1044
1742
  for (let i = 0; i < args.length; i++) {
1045
1743
  const arg = args[i];
1744
+ if (arg === "--cv") {
1745
+ cv = true;
1746
+ continue;
1747
+ }
1046
1748
  if (arg === "--output" || arg === "--base-url") {
1047
1749
  const value = args[i + 1];
1048
1750
  if (value === void 0 || value === "" || value.startsWith("-")) {
@@ -1077,10 +1779,11 @@ function parseArgs3(args) {
1077
1779
  return { error: "takuhon: `--base-url` must be an absolute http(s) URL." };
1078
1780
  }
1079
1781
  return {
1080
- path: path ?? DEFAULT_PATH3,
1782
+ path: path ?? DEFAULT_PATH4,
1081
1783
  output: output ?? DEFAULT_OUTPUT,
1082
1784
  // Drop any trailing slash so URL joins are predictable.
1083
- baseUrl: baseUrl?.replace(/\/+$/, "")
1785
+ baseUrl: baseUrl?.replace(/\/+$/, ""),
1786
+ cv
1084
1787
  };
1085
1788
  }
1086
1789
  function isHttpUrl3(value) {
@@ -1092,10 +1795,10 @@ function isHttpUrl3(value) {
1092
1795
  }
1093
1796
  }
1094
1797
  function buildSite(parsed) {
1095
- const { path, output, baseUrl } = parsed;
1798
+ const { path, output, baseUrl, cv } = parsed;
1096
1799
  let raw;
1097
1800
  try {
1098
- raw = readFileSync4(path, "utf8");
1801
+ raw = readFileSync7(path, "utf8");
1099
1802
  } catch {
1100
1803
  return {
1101
1804
  code: 2,
@@ -1124,11 +1827,12 @@ ${lines.join("\n")}
1124
1827
  };
1125
1828
  }
1126
1829
  const filtered = applyPublicPrivacyFilter2(normalize2(result.data));
1830
+ const activitySnapshot = filtered.settings.activity?.enabled === true ? readActivitySnapshotSync(path) : null;
1127
1831
  const written = [];
1128
1832
  try {
1129
- for (const page of generateSite(filtered, { baseUrl })) {
1130
- const outFile = join3(output, page.file);
1131
- mkdirSync2(dirname2(outFile), { recursive: true });
1833
+ for (const page of generateSite(filtered, { baseUrl, activitySnapshot, cv })) {
1834
+ const outFile = join6(output, page.file);
1835
+ mkdirSync3(dirname5(outFile), { recursive: true });
1132
1836
  writeFileAtomic(outFile, page.html);
1133
1837
  written.push(outFile);
1134
1838
  }
@@ -1148,10 +1852,10 @@ ${summary}
1148
1852
  }
1149
1853
 
1150
1854
  // src/export-command.ts
1151
- import { readFileSync as readFileSync5 } from "fs";
1152
- import { resolve as resolve2 } from "path";
1855
+ import { readFileSync as readFileSync8 } from "fs";
1856
+ import { resolve as resolve4 } from "path";
1153
1857
  import { exportTakuhon, validate as validate3 } from "@takuhon/core";
1154
- var DEFAULT_PATH4 = "takuhon.json";
1858
+ var DEFAULT_PATH5 = "takuhon.json";
1155
1859
  var USAGE4 = `Usage: takuhon export [path] [--output <file>]
1156
1860
 
1157
1861
  Serialise a takuhon.json into its transport form and print it to stdout, or
@@ -1215,11 +1919,11 @@ function parseArgs4(args) {
1215
1919
  }
1216
1920
  path = arg;
1217
1921
  }
1218
- return { path: path ?? DEFAULT_PATH4, output };
1922
+ return { path: path ?? DEFAULT_PATH5, output };
1219
1923
  }
1220
1924
  function exportFile(parsed) {
1221
1925
  const { path, output } = parsed;
1222
- if (output !== void 0 && resolve2(output) === resolve2(path)) {
1926
+ if (output !== void 0 && resolve4(output) === resolve4(path)) {
1223
1927
  return {
1224
1928
  code: 2,
1225
1929
  stdout: "",
@@ -1230,7 +1934,7 @@ Omit --output to print to stdout, or choose a different file.
1230
1934
  }
1231
1935
  let raw;
1232
1936
  try {
1233
- raw = readFileSync5(path, "utf8");
1937
+ raw = readFileSync8(path, "utf8");
1234
1938
  } catch {
1235
1939
  return {
1236
1940
  code: 2,
@@ -1275,7 +1979,7 @@ ${lines.join("\n")}
1275
1979
  }
1276
1980
 
1277
1981
  // src/import-command.ts
1278
- import { readFileSync as readFileSync6 } from "fs";
1982
+ import { readFileSync as readFileSync9 } from "fs";
1279
1983
  import {
1280
1984
  ImportError,
1281
1985
  MigrationError,
@@ -1283,7 +1987,7 @@ import {
1283
1987
  importTakuhon,
1284
1988
  migrateTakuhon
1285
1989
  } from "@takuhon/core";
1286
- var DEFAULT_PATH5 = "takuhon.json";
1990
+ var DEFAULT_PATH6 = "takuhon.json";
1287
1991
  var USAGE5 = `Usage: takuhon import <file> [path]
1288
1992
 
1289
1993
  Load a previously exported profile from <file> into a local takuhon.json,
@@ -1327,13 +2031,13 @@ function parseArgs5(args) {
1327
2031
  if (positionals.length > 2) {
1328
2032
  return { error: "takuhon: `import` takes at most an input <file> and a target path." };
1329
2033
  }
1330
- return { file: positionals[0], path: positionals[1] ?? DEFAULT_PATH5 };
2034
+ return { file: positionals[0], path: positionals[1] ?? DEFAULT_PATH6 };
1331
2035
  }
1332
2036
  function importFile(parsed, now) {
1333
2037
  const { file, path } = parsed;
1334
2038
  let raw;
1335
2039
  try {
1336
- raw = readFileSync6(file, "utf8");
2040
+ raw = readFileSync9(file, "utf8");
1337
2041
  } catch {
1338
2042
  return { code: 2, stdout: "", stderr: `takuhon: cannot read '${file}'.
1339
2043
  ` };
@@ -1398,9 +2102,9 @@ ${lines2.join("\n")}` : ".";
1398
2102
  }
1399
2103
  let currentRaw;
1400
2104
  try {
1401
- currentRaw = readFileSync6(path, "utf8");
2105
+ currentRaw = readFileSync9(path, "utf8");
1402
2106
  } catch (error) {
1403
- if (!isNotFound3(error)) {
2107
+ if (!isNotFound5(error)) {
1404
2108
  return { code: 2, stdout: "", stderr: `takuhon: cannot read current profile '${path}'.
1405
2109
  ` };
1406
2110
  }
@@ -1439,13 +2143,13 @@ ${lines2.join("\n")}` : ".";
1439
2143
  return { code: 0, stdout: `${lines.join("\n")}
1440
2144
  `, stderr: "" };
1441
2145
  }
1442
- function isNotFound3(error) {
2146
+ function isNotFound5(error) {
1443
2147
  return typeof error === "object" && error !== null && error.code === "ENOENT";
1444
2148
  }
1445
2149
 
1446
2150
  // src/migrate-command.ts
1447
- import { readFileSync as readFileSync7 } from "fs";
1448
- import { resolve as resolve3 } from "path";
2151
+ import { readFileSync as readFileSync10 } from "fs";
2152
+ import { resolve as resolve5 } from "path";
1449
2153
  import {
1450
2154
  MigrationError as MigrationError2,
1451
2155
  SCHEMA_VERSION as SCHEMA_VERSION2,
@@ -1453,7 +2157,7 @@ import {
1453
2157
  migrateTakuhon as migrateTakuhon2,
1454
2158
  validate as validate4
1455
2159
  } from "@takuhon/core";
1456
- var DEFAULT_PATH6 = "takuhon.json";
2160
+ var DEFAULT_PATH7 = "takuhon.json";
1457
2161
  var USAGE6 = `Usage: takuhon migrate [path] [--to <version>] [--out <file>] [--dry-run]
1458
2162
 
1459
2163
  Forward-migrate a takuhon.json to a newer schema version. The source version
@@ -1534,13 +2238,13 @@ function parseArgs6(args) {
1534
2238
  error: `takuhon: unsupported --to version "${target}". Supported: ${SUPPORTED_SCHEMA_VERSIONS.join(", ")}.`
1535
2239
  };
1536
2240
  }
1537
- return { path: path ?? DEFAULT_PATH6, to: target, out, dryRun };
2241
+ return { path: path ?? DEFAULT_PATH7, to: target, out, dryRun };
1538
2242
  }
1539
2243
  function migrateFile(parsed, now) {
1540
2244
  const { path, to: target, out, dryRun } = parsed;
1541
2245
  let raw;
1542
2246
  try {
1543
- raw = readFileSync7(path, "utf8");
2247
+ raw = readFileSync10(path, "utf8");
1544
2248
  } catch {
1545
2249
  return {
1546
2250
  code: 2,
@@ -1575,7 +2279,7 @@ function migrateFile(parsed, now) {
1575
2279
  };
1576
2280
  }
1577
2281
  const writeTarget = out ?? path;
1578
- const inPlace = resolve3(writeTarget) === resolve3(path);
2282
+ const inPlace = resolve5(writeTarget) === resolve5(path);
1579
2283
  let migrated;
1580
2284
  try {
1581
2285
  migrated = migrateTakuhon2(data, target);
@@ -1653,11 +2357,119 @@ ${lines2.join("\n")}
1653
2357
  `, stderr: "" };
1654
2358
  }
1655
2359
 
2360
+ // src/refresh-admin-command.ts
2361
+ import { readFileSync as readFileSync11 } from "fs";
2362
+ import { cp, mkdtemp, readdir, rename, rm, stat } from "fs/promises";
2363
+ import { join as join7, resolve as resolve6 } from "path";
2364
+ var PROFILE_FILENAME = "takuhon.json";
2365
+ var USAGE7 = `Usage: takuhon admin update [path]
2366
+
2367
+ Refresh a project's ${ADMIN_DIST_DIRNAME}/ with the admin form-UI bundle shipped in
2368
+ this @takuhon/cli. Use it after upgrading @takuhon/cli to pick up a newer admin
2369
+ UI without re-scaffolding. With no path, refreshes the project in the current
2370
+ directory. The project must already have an ${ADMIN_DIST_DIRNAME}/ (created by
2371
+ create-takuhon); this command updates it, it does not create one.
2372
+
2373
+ Exit codes: 0 = refreshed, 2 = bad arguments / not a takuhon project /
2374
+ no ${ADMIN_DIST_DIRNAME}/ to refresh / copy failed.
2375
+ `;
2376
+ async function runRefreshAdmin(args = [], opts = {}) {
2377
+ if (args[0] === "--help" || args[0] === "-h") {
2378
+ return { code: 0, stdout: USAGE7, stderr: "" };
2379
+ }
2380
+ const unknownFlag = args.find((arg) => arg.startsWith("-"));
2381
+ if (unknownFlag !== void 0) {
2382
+ return usageError(`unknown option '${unknownFlag}'.`);
2383
+ }
2384
+ if (args.length > 1) {
2385
+ return usageError("`admin update` takes at most one path argument.");
2386
+ }
2387
+ const displayDir = args[0] ?? ".";
2388
+ const projectDir = resolve6(args[0] ?? ".");
2389
+ try {
2390
+ await stat(join7(projectDir, PROFILE_FILENAME));
2391
+ } catch {
2392
+ return {
2393
+ code: 2,
2394
+ stdout: "",
2395
+ stderr: `takuhon: '${displayDir}' is not a takuhon project (no ${PROFILE_FILENAME} found).
2396
+ Run \`takuhon admin update\` from a project created by create-takuhon.
2397
+ `
2398
+ };
2399
+ }
2400
+ const adminDist = join7(projectDir, ADMIN_DIST_DIRNAME);
2401
+ const adminDistStat = await stat(adminDist).catch(() => void 0);
2402
+ if (adminDistStat === void 0) {
2403
+ return {
2404
+ code: 2,
2405
+ stdout: "",
2406
+ stderr: `takuhon: '${displayDir}' has no ${ADMIN_DIST_DIRNAME}/ directory to refresh.
2407
+ This project was not scaffolded with the admin form UI; create a new project with create-takuhon to add it.
2408
+ `
2409
+ };
2410
+ }
2411
+ if (!adminDistStat.isDirectory()) {
2412
+ return {
2413
+ code: 2,
2414
+ stdout: "",
2415
+ stderr: `takuhon: '${join7(displayDir, ADMIN_DIST_DIRNAME)}' exists but is not a directory.
2416
+ `
2417
+ };
2418
+ }
2419
+ const bundleDir = opts.bundleDir ?? resolveAdminBundleDir();
2420
+ let fileCount;
2421
+ let staged;
2422
+ try {
2423
+ fileCount = (await readdir(bundleDir, { recursive: true, withFileTypes: true })).filter(
2424
+ (entry) => entry.isFile()
2425
+ ).length;
2426
+ staged = await mkdtemp(join7(projectDir, `.${ADMIN_DIST_DIRNAME}-`));
2427
+ await cp(bundleDir, staged, { recursive: true });
2428
+ await rm(adminDist, { recursive: true, force: true });
2429
+ await rename(staged, adminDist);
2430
+ staged = void 0;
2431
+ } catch (err) {
2432
+ if (staged !== void 0) {
2433
+ await rm(staged, { recursive: true, force: true }).catch(() => void 0);
2434
+ }
2435
+ const code = isNodeErrnoException(err) ? ` (${err.code})` : "";
2436
+ return {
2437
+ code: 2,
2438
+ stdout: "",
2439
+ stderr: `takuhon: failed to refresh ${ADMIN_DIST_DIRNAME}/ from @takuhon/cli${code}. Reinstall or upgrade @takuhon/cli and try again.
2440
+ `
2441
+ };
2442
+ }
2443
+ const plural = fileCount === 1 ? "" : "s";
2444
+ return {
2445
+ code: 0,
2446
+ stdout: `Refreshed ${ADMIN_DIST_DIRNAME}/ from @takuhon/cli@${readVersion()} (${fileCount} file${plural}).
2447
+ `,
2448
+ stderr: ""
2449
+ };
2450
+ }
2451
+ function usageError(message) {
2452
+ return {
2453
+ code: 2,
2454
+ stdout: "",
2455
+ stderr: `takuhon: ${message}
2456
+ Run \`takuhon admin update --help\` for usage.
2457
+ `
2458
+ };
2459
+ }
2460
+ function readVersion() {
2461
+ const pkg2 = JSON.parse(readFileSync11(new URL("../package.json", import.meta.url), "utf8"));
2462
+ return pkg2.version;
2463
+ }
2464
+ function isNodeErrnoException(err) {
2465
+ return err instanceof Error && typeof err.code === "string";
2466
+ }
2467
+
1656
2468
  // src/restore-command.ts
1657
- import { readFileSync as readFileSync8 } from "fs";
2469
+ import { readFileSync as readFileSync12 } from "fs";
1658
2470
  import { validate as validate5 } from "@takuhon/core";
1659
- var DEFAULT_PATH7 = "takuhon.json";
1660
- var USAGE7 = `Usage: takuhon restore --from <backup> [path] [--yes]
2471
+ var DEFAULT_PATH8 = "takuhon.json";
2472
+ var USAGE8 = `Usage: takuhon restore --from <backup> [path] [--yes]
1661
2473
 
1662
2474
  Overwrite a profile with a previously saved backup. With no path, restores
1663
2475
  ./takuhon.json in the current working directory.
@@ -1674,7 +2486,7 @@ Exit codes: 0 = restored / aborted, 1 = backup failed validation,
1674
2486
  `;
1675
2487
  async function runRestore(args = [], deps = {}) {
1676
2488
  if (args[0] === "--help" || args[0] === "-h") {
1677
- return { code: 0, stdout: USAGE7, stderr: "" };
2489
+ return { code: 0, stdout: USAGE8, stderr: "" };
1678
2490
  }
1679
2491
  const parsed = parseArgs7(args);
1680
2492
  if ("error" in parsed) {
@@ -1723,13 +2535,13 @@ function parseArgs7(args) {
1723
2535
  if (from === void 0 || from.length === 0) {
1724
2536
  return { error: "takuhon: `restore` requires `--from <backup>`." };
1725
2537
  }
1726
- return { from, path: path ?? DEFAULT_PATH7, yes };
2538
+ return { from, path: path ?? DEFAULT_PATH8, yes };
1727
2539
  }
1728
2540
  async function restoreFile(parsed, now, confirm) {
1729
2541
  const { from, path, yes } = parsed;
1730
2542
  let backupRaw;
1731
2543
  try {
1732
- backupRaw = readFileSync8(from, "utf8");
2544
+ backupRaw = readFileSync12(from, "utf8");
1733
2545
  } catch {
1734
2546
  return { code: 2, stdout: "", stderr: `takuhon: cannot read backup '${from}'.
1735
2547
  ` };
@@ -1759,9 +2571,9 @@ ${lines2.join("\n")}
1759
2571
  }
1760
2572
  let currentRaw;
1761
2573
  try {
1762
- currentRaw = readFileSync8(path, "utf8");
2574
+ currentRaw = readFileSync12(path, "utf8");
1763
2575
  } catch (error) {
1764
- if (!isNotFound4(error)) {
2576
+ if (!isNotFound6(error)) {
1765
2577
  return { code: 2, stdout: "", stderr: `takuhon: cannot read current profile '${path}'.
1766
2578
  ` };
1767
2579
  }
@@ -1824,17 +2636,17 @@ function confirmationMessage(path, from, data, currentRaw, preRestorePath) {
1824
2636
  ${preNote}
1825
2637
  Continue? [y/N]`;
1826
2638
  }
1827
- function isNotFound4(error) {
2639
+ function isNotFound6(error) {
1828
2640
  return typeof error === "object" && error !== null && error.code === "ENOENT";
1829
2641
  }
1830
2642
 
1831
2643
  // src/sync-command.ts
1832
- import { readFileSync as readFileSync9 } from "fs";
2644
+ import { readFileSync as readFileSync13 } from "fs";
1833
2645
  import { validate as validate6 } from "@takuhon/core";
1834
- var DEFAULT_PATH8 = "takuhon.json";
2646
+ var DEFAULT_PATH9 = "takuhon.json";
1835
2647
  var ADMIN_PROFILE_PATH = "/api/admin/profile";
1836
2648
  var TOKEN_ENV = "TAKUHON_ADMIN_TOKEN";
1837
- var USAGE8 = `Usage: takuhon sync [path] --url <base-url> [--if-match <etag>] [--dry-run]
2649
+ var USAGE9 = `Usage: takuhon sync [path] --url <base-url> [--if-match <etag>] [--dry-run]
1838
2650
 
1839
2651
  Push a local takuhon.json to a deployed takuhon instance by calling its admin
1840
2652
  write endpoint (PUT <base-url>/api/admin/profile). With no path, syncs
@@ -1860,7 +2672,7 @@ unset / auth failure / network error / other non-success response.
1860
2672
  `;
1861
2673
  async function runSync(args = [], deps = {}) {
1862
2674
  if (args[0] === "--help" || args[0] === "-h") {
1863
- return { code: 0, stdout: USAGE8, stderr: "" };
2675
+ return { code: 0, stdout: USAGE9, stderr: "" };
1864
2676
  }
1865
2677
  const parsed = parseArgs8(args);
1866
2678
  if ("error" in parsed) {
@@ -1920,7 +2732,7 @@ function parseArgs8(args) {
1920
2732
  }
1921
2733
  const base = parseOrigin(url);
1922
2734
  if ("error" in base) return base;
1923
- return { path: path ?? DEFAULT_PATH8, url: base.origin, ifMatch, dryRun };
2735
+ return { path: path ?? DEFAULT_PATH9, url: base.origin, ifMatch, dryRun };
1924
2736
  }
1925
2737
  function parseOrigin(value) {
1926
2738
  let parsed;
@@ -1953,7 +2765,7 @@ async function syncProfile(parsed, deps) {
1953
2765
  const { path, url, ifMatch, dryRun } = parsed;
1954
2766
  let raw;
1955
2767
  try {
1956
- raw = readFileSync9(path, "utf8");
2768
+ raw = readFileSync13(path, "utf8");
1957
2769
  } catch {
1958
2770
  return { code: 2, stdout: "", stderr: `takuhon: cannot read '${path}'.
1959
2771
  ` };
@@ -2024,7 +2836,7 @@ Set it for this command only, e.g.:
2024
2836
  async function interpretResponse(res, target) {
2025
2837
  const { path, url, endpoint } = target;
2026
2838
  if (res.ok) {
2027
- const version = await readVersion(res);
2839
+ const version = await readVersion2(res);
2028
2840
  if (version === void 0) {
2029
2841
  return {
2030
2842
  code: 2,
@@ -2066,7 +2878,7 @@ ${lines.join("\n")}` : ".";
2066
2878
  return { code: 2, stdout: "", stderr: `takuhon: sync failed (${String(status)})${tail}
2067
2879
  ` };
2068
2880
  }
2069
- async function readVersion(res) {
2881
+ async function readVersion2(res) {
2070
2882
  try {
2071
2883
  const parsed = await res.json();
2072
2884
  const version = parsed.meta?.version;
@@ -2090,10 +2902,10 @@ async function readProblem(res) {
2090
2902
  }
2091
2903
 
2092
2904
  // src/validate-command.ts
2093
- import { readFileSync as readFileSync10 } from "fs";
2905
+ import { readFileSync as readFileSync14 } from "fs";
2094
2906
  import { validate as validate7 } from "@takuhon/core";
2095
- var DEFAULT_PATH9 = "takuhon.json";
2096
- var USAGE9 = `Usage: takuhon validate [path]
2907
+ var DEFAULT_PATH10 = "takuhon.json";
2908
+ var USAGE10 = `Usage: takuhon validate [path]
2097
2909
 
2098
2910
  Validate a takuhon.json against the takuhon schema. With no path, validates
2099
2911
  ./takuhon.json in the current working directory.
@@ -2102,7 +2914,7 @@ Exit codes: 0 = valid, 1 = invalid, 2 = file missing / unreadable / not JSON.
2102
2914
  `;
2103
2915
  function runValidate(args = []) {
2104
2916
  if (args[0] === "--help" || args[0] === "-h") {
2105
- return { code: 0, stdout: USAGE9, stderr: "" };
2917
+ return { code: 0, stdout: USAGE10, stderr: "" };
2106
2918
  }
2107
2919
  if (args.length > 1) {
2108
2920
  return {
@@ -2114,10 +2926,10 @@ function runValidate(args = []) {
2114
2926
  return validateFile(args[0]);
2115
2927
  }
2116
2928
  function validateFile(pathArg) {
2117
- const target = pathArg ?? DEFAULT_PATH9;
2929
+ const target = pathArg ?? DEFAULT_PATH10;
2118
2930
  let raw;
2119
2931
  try {
2120
- raw = readFileSync10(target, "utf8");
2932
+ raw = readFileSync14(target, "utf8");
2121
2933
  } catch {
2122
2934
  return {
2123
2935
  code: 2,
@@ -2159,7 +2971,7 @@ ${lines.join("\n")}
2159
2971
  }
2160
2972
 
2161
2973
  // src/index.ts
2162
- var pkg = JSON.parse(readFileSync11(new URL("../package.json", import.meta.url), "utf8"));
2974
+ var pkg = JSON.parse(readFileSync15(new URL("../package.json", import.meta.url), "utf8"));
2163
2975
  var VERSION = pkg.version;
2164
2976
  var HELP = `takuhon ${VERSION}
2165
2977
 
@@ -2189,10 +3001,18 @@ Commands:
2189
3001
  the form UI at /admin (writes takuhon.json, backs up
2190
3002
  first) with a preview at / (default port: 4322). Binds
2191
3003
  127.0.0.1; prints a per-run token to paste into the form.
3004
+ takuhon admin update [path] Refresh a project's admin-dist/ with the admin form UI
3005
+ bundled in this @takuhon/cli (use after upgrading the
3006
+ CLI). Updates an existing admin-dist/ only.
2192
3007
  takuhon sync [path] --url <url> Push a takuhon.json to a deployment's admin API
2193
3008
  (PUT <url>/api/admin/profile). Reads the admin token
2194
3009
  from TAKUHON_ADMIN_TOKEN. --if-match <etag> opts into
2195
3010
  optimistic locking; --dry-run previews without sending.
3011
+ takuhon activity sync [path] Fetch the GitHub / WakaTime activity configured in
3012
+ settings.activity and store it as activity.json beside
3013
+ the profile. Secrets come from the environment only:
3014
+ TAKUHON_GITHUB_TOKEN (optional) / TAKUHON_WAKATIME_KEY.
3015
+ takuhon activity show [path] Print the stored activity snapshot (activity.json).
2196
3016
 
2197
3017
  Scaffolding a new profile project:
2198
3018
  npx create-takuhon my-profile
@@ -2225,6 +3045,9 @@ async function main(argv) {
2225
3045
  return runDev(argv.slice(1));
2226
3046
  }
2227
3047
  if (first === "admin") {
3048
+ if (argv[1] === "update") {
3049
+ return emit(await runRefreshAdmin(argv.slice(2)));
3050
+ }
2228
3051
  return runAdmin(argv.slice(1));
2229
3052
  }
2230
3053
  if (first === "import") {
@@ -2237,6 +3060,26 @@ async function main(argv) {
2237
3060
  if (first === "sync") {
2238
3061
  return emit(await runSync(argv.slice(1)));
2239
3062
  }
3063
+ if (first === "activity") {
3064
+ const sub = argv[1];
3065
+ if (sub === "sync") {
3066
+ return emit(await runActivitySync(argv.slice(2)));
3067
+ }
3068
+ if (sub === "show") {
3069
+ return emit(await runActivityShow(argv.slice(2)));
3070
+ }
3071
+ const usage = "Subcommands:\n takuhon activity sync [path] Fetch and store the activity snapshot\n takuhon activity show [path] Print the stored activity snapshot\nRun `takuhon activity sync --help` for details.\n";
3072
+ if (sub === "--help" || sub === "-h") {
3073
+ process.stdout.write(usage);
3074
+ return 0;
3075
+ }
3076
+ process.stderr.write(
3077
+ sub === void 0 ? `takuhon: \`activity\` requires a subcommand.
3078
+ ${usage}` : `takuhon: unknown subcommand \`${sub}\` for \`activity\`.
3079
+ ${usage}`
3080
+ );
3081
+ return 2;
3082
+ }
2240
3083
  process.stderr.write(
2241
3084
  `takuhon: unknown command '${first}'
2242
3085
  Run \`takuhon --help\` for usage. For scaffolding a new project, use \`create-takuhon\`.