@takuhon/cli 0.11.0 → 0.12.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-XMSWT5NY.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,12 +349,12 @@ 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";
32
358
  function escapeHtml(value) {
33
359
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
34
360
  }
@@ -64,6 +390,7 @@ ul{padding:0;margin:0;list-style:none}
64
390
  .rec blockquote{margin:0;padding-left:.9rem;border-left:3px solid var(--line)}
65
391
  .rec figcaption{color:var(--muted);font-size:.9rem;margin-top:.3rem}
66
392
  nav.locales{display:flex;gap:.75rem;margin-bottom:1rem;font-size:.9rem}
393
+ .activity svg{max-width:100%;height:auto}
67
394
  footer.powered{max-width:42rem;margin:0 auto;padding:1.5rem 1.25rem;color:var(--muted);font-size:.85rem}`;
68
395
  function dateRange(start, end, isCurrent) {
69
396
  const left = start ?? "";
@@ -151,6 +478,12 @@ function renderContact(contact) {
151
478
  if (items.length === 0) return "";
152
479
  return `<section><h2>Contact</h2><ul class="entries">${items.join("")}</ul></section>`;
153
480
  }
481
+ function renderActivity(snapshot) {
482
+ if (!snapshot) return "";
483
+ const svg = renderActivitySvg(snapshot);
484
+ if (svg === "") return "";
485
+ return `<section class="activity"><h2>Activity</h2>${svg}</section>`;
486
+ }
154
487
  function renderJsonLdScript(data) {
155
488
  const payload = JSON.stringify(generateJsonLd(data));
156
489
  return `<script type="application/ld+json">${escapeJsonLd(payload)}</script>`;
@@ -202,6 +535,7 @@ function renderProfileHtml(input) {
202
535
  }))
203
536
  ),
204
537
  renderSkills(d.skills),
538
+ renderActivity(input.activitySnapshot),
205
539
  entryList(
206
540
  "Education",
207
541
  d.education.map((e) => {
@@ -320,6 +654,7 @@ function generateSite(profile, options = {}) {
320
654
  const defaultLocale = profile.settings.defaultLocale;
321
655
  const locales = [.../* @__PURE__ */ new Set([defaultLocale, ...profile.settings.availableLocales])];
322
656
  const jsonLd = profile.settings.enableJsonLd !== false;
657
+ const activitySnapshot = profile.settings.activity?.enabled === true ? options.activitySnapshot ?? void 0 : void 0;
323
658
  return locales.map((locale) => {
324
659
  const localized = resolveLocale(profile, locale);
325
660
  const isDefault = locale === defaultLocale;
@@ -330,7 +665,14 @@ function generateSite(profile, options = {}) {
330
665
  }));
331
666
  const canonicalUrl = baseUrl ? absoluteUrl(baseUrl, locale, defaultLocale) : void 0;
332
667
  const alternates = baseUrl ? buildAlternates(baseUrl, locales, defaultLocale) : [];
333
- const html = renderProfileHtml({ localized, canonicalUrl, alternates, localeNav, jsonLd });
668
+ const html = renderProfileHtml({
669
+ localized,
670
+ canonicalUrl,
671
+ alternates,
672
+ localeNav,
673
+ jsonLd,
674
+ activitySnapshot
675
+ });
334
676
  return {
335
677
  route: isDefault ? "/" : `/${locale}/`,
336
678
  file: isDefault ? "index.html" : `${locale}/index.html`,
@@ -360,7 +702,7 @@ function localeHref(from, to, defaultLocale) {
360
702
  }
361
703
 
362
704
  // src/dev-command.ts
363
- var DEFAULT_PATH = "takuhon.json";
705
+ var DEFAULT_PATH2 = "takuhon.json";
364
706
  var DEFAULT_PORT = 4321;
365
707
  var USAGE = `Usage: takuhon dev [path] [--port <n>] [--base-url <url>]
366
708
 
@@ -383,7 +725,7 @@ unreadable / port in use.
383
725
  function loadSiteState(path, baseUrl) {
384
726
  let raw;
385
727
  try {
386
- raw = readFileSync(path, "utf8");
728
+ raw = readFileSync3(path, "utf8");
387
729
  } catch {
388
730
  return { ok: false, status: 500, message: `cannot read '${path}'.` };
389
731
  }
@@ -405,7 +747,10 @@ ${lines.join("\n")}`
405
747
  };
406
748
  }
407
749
  const filtered = applyPublicPrivacyFilter(normalize(result.data));
408
- const pages = new Map(generateSite(filtered, { baseUrl }).map((p) => [p.route, p.html]));
750
+ const activitySnapshot = filtered.settings.activity?.enabled === true ? readActivitySnapshotSync(path) : null;
751
+ const pages = new Map(
752
+ generateSite(filtered, { baseUrl, activitySnapshot }).map((p) => [p.route, p.html])
753
+ );
409
754
  return { ok: true, pages };
410
755
  }
411
756
  function resolveRoute(urlPath) {
@@ -470,7 +815,7 @@ Run \`takuhon dev --help\` for usage.
470
815
  return 2;
471
816
  }
472
817
  try {
473
- readFileSync(parsed.path, "utf8");
818
+ readFileSync3(parsed.path, "utf8");
474
819
  } catch {
475
820
  err(
476
821
  `takuhon: cannot read '${parsed.path}'. Pass a path, or run from a directory containing a takuhon.json.
@@ -479,14 +824,14 @@ Run \`takuhon dev --help\` for usage.
479
824
  return 2;
480
825
  }
481
826
  const server = createDevServer({ path: parsed.path, baseUrl: parsed.baseUrl });
482
- return await new Promise((resolve4) => {
827
+ return await new Promise((resolve7) => {
483
828
  let closing = false;
484
829
  const shutdown = () => {
485
830
  if (closing) return;
486
831
  closing = true;
487
832
  process.removeListener("SIGINT", shutdown);
488
833
  process.removeListener("SIGTERM", shutdown);
489
- server.close(() => resolve4(0));
834
+ server.close(() => resolve7(0));
490
835
  server.closeAllConnections();
491
836
  };
492
837
  server.once("error", (error) => {
@@ -497,7 +842,7 @@ Run \`takuhon dev --help\` for usage.
497
842
  err(`takuhon: ${error.message}
498
843
  `);
499
844
  }
500
- resolve4(2);
845
+ resolve7(2);
501
846
  });
502
847
  server.listen(parsed.port, "127.0.0.1", () => {
503
848
  out(
@@ -566,7 +911,7 @@ function parseArgs(args) {
566
911
  return { error: "takuhon: `--base-url` must be an absolute http(s) URL." };
567
912
  }
568
913
  return {
569
- path: path ?? DEFAULT_PATH,
914
+ path: path ?? DEFAULT_PATH2,
570
915
  port,
571
916
  // Drop any trailing slash so URL joins are predictable.
572
917
  baseUrl: baseUrl?.replace(/\/+$/, "")
@@ -629,91 +974,177 @@ function renderNotFoundPage(route, routes) {
629
974
  );
630
975
  }
631
976
 
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`;
977
+ // src/file-asset-storage.ts
978
+ import { randomBytes } from "crypto";
979
+ import {
980
+ existsSync,
981
+ mkdirSync as mkdirSync2,
982
+ readdirSync,
983
+ readFileSync as readFileSync4,
984
+ renameSync as renameSync2,
985
+ rmSync as rmSync2,
986
+ statSync,
987
+ writeFileSync as writeFileSync2
988
+ } from "fs";
989
+ import { basename as basename2, dirname as dirname4, extname, join as join4, resolve as resolve2, sep } from "path";
990
+ import {
991
+ ACCEPTED_IMAGE_MIME_TYPES,
992
+ detectImageMime,
993
+ IMAGE_EXTENSIONS,
994
+ NotFoundError,
995
+ readImageInfo
996
+ } from "@takuhon/core";
997
+ var ASSET_DIRNAME = "assets";
998
+ var ASSET_KEY_PREFIX = `${ASSET_DIRNAME}/`;
999
+ var DEFAULT_EXTENSION = "bin";
1000
+ var MIME_BY_EXTENSION = new Map(
1001
+ Object.entries(IMAGE_EXTENSIONS).map(([mime, ext]) => [ext, mime])
1002
+ );
1003
+ function asAcceptedMime(value) {
1004
+ if (value === void 0) return null;
1005
+ return ACCEPTED_IMAGE_MIME_TYPES.includes(value) ? value : null;
1006
+ }
1007
+ function contentTypeForFile(name) {
1008
+ const ext = extname(name).slice(1).toLowerCase();
1009
+ return MIME_BY_EXTENSION.get(ext) ?? "application/octet-stream";
1010
+ }
1011
+ function timestampSeconds() {
1012
+ return Math.floor(Date.now() / 1e3);
1013
+ }
1014
+ function shortHash() {
1015
+ return randomBytes(2).toString("hex");
663
1016
  }
664
- function backupDirFor(targetPath) {
665
- return join(dirname(targetPath), BACKUP_DIR_NAME);
1017
+ function isNotFound2(err) {
1018
+ return typeof err === "object" && err !== null && err.code === "ENOENT";
666
1019
  }
667
- function createBackup(params) {
668
- const dir = backupDirFor(params.targetPath);
669
- mkdirSync(dir, { recursive: true });
670
- const primary = join(dir, params.name(false));
1020
+ function writeFileAtomicBinary(target, bytes) {
1021
+ const tmp = join4(dirname4(target), `.${basename2(target)}.${String(process.pid)}.tmp`);
671
1022
  try {
672
- writeFileSync(primary, params.content, { flag: "wx" });
673
- return primary;
1023
+ writeFileSync2(tmp, bytes);
1024
+ renameSync2(tmp, target);
674
1025
  } catch (error) {
675
- if (!isAlreadyExists(error)) throw error;
676
- }
677
- const fallback = join(dir, params.name(true));
678
- try {
679
- writeFileSync(fallback, params.content, { flag: "wx" });
680
- return fallback;
681
- } 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
- }
1026
+ rmSync2(tmp, { force: true });
688
1027
  throw error;
689
1028
  }
690
1029
  }
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;
1030
+ var FileTakuhonAssetStorage = class {
1031
+ profilePath;
1032
+ publicBaseUrl;
1033
+ constructor(profilePath, options = {}) {
1034
+ this.profilePath = profilePath;
1035
+ this.publicBaseUrl = options.publicBaseUrl ?? "";
1036
+ }
1037
+ // Genuinely async (awaits the Blob), so no `Promise.resolve().then()` wrap is
1038
+ // needed here — unlike the synchronous methods below.
1039
+ async putAsset(file, options) {
1040
+ const bytes = new Uint8Array(await file.arrayBuffer());
1041
+ const mime = asAcceptedMime(options?.contentType) ?? detectImageMime(bytes);
1042
+ const ext = mime !== null ? IMAGE_EXTENSIONS[mime] : DEFAULT_EXTENSION;
1043
+ const contentType2 = mime ?? options?.contentType ?? file.type ?? "application/octet-stream";
1044
+ const key = `${ASSET_KEY_PREFIX}${String(timestampSeconds())}-${shortHash()}.${ext}`;
1045
+ const full = join4(this.projectDir, key);
1046
+ mkdirSync2(dirname4(full), { recursive: true });
1047
+ writeFileAtomicBinary(full, bytes);
1048
+ const info = mime !== null ? readImageInfo(bytes, mime) : null;
1049
+ const url = `/${key}`;
1050
+ return {
1051
+ id: key,
1052
+ url,
1053
+ publicUrl: this.absoluteUrl(url),
1054
+ mimeType: contentType2,
1055
+ size: bytes.length,
1056
+ width: info?.width,
1057
+ height: info?.height,
1058
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1059
+ };
702
1060
  }
703
- }
1061
+ getPublicUrl(assetId) {
1062
+ return Promise.resolve().then(() => {
1063
+ const full = this.resolveKey(assetId);
1064
+ if (full === null || !existsSync(full)) {
1065
+ throw new NotFoundError(`No asset is stored for "${assetId}".`);
1066
+ }
1067
+ return this.absoluteUrl(`/${assetId}`);
1068
+ });
1069
+ }
1070
+ deleteAsset(assetId) {
1071
+ return Promise.resolve().then(() => {
1072
+ const full = this.resolveKey(assetId);
1073
+ if (full !== null) rmSync2(full, { force: true });
1074
+ });
1075
+ }
1076
+ listAssets() {
1077
+ return Promise.resolve().then(() => {
1078
+ const root = join4(this.projectDir, ASSET_DIRNAME);
1079
+ let names;
1080
+ try {
1081
+ names = readdirSync(root);
1082
+ } catch (err) {
1083
+ if (isNotFound2(err)) return [];
1084
+ throw err;
1085
+ }
1086
+ return names.filter((name) => statSync(join4(root, name)).isFile()).sort().map((name) => {
1087
+ const key = `${ASSET_KEY_PREFIX}${name}`;
1088
+ const url = `/${key}`;
1089
+ return {
1090
+ id: key,
1091
+ url,
1092
+ publicUrl: this.absoluteUrl(url),
1093
+ mimeType: contentTypeForFile(name),
1094
+ size: statSync(join4(root, name)).size,
1095
+ createdAt: statSync(join4(root, name)).mtime.toISOString()
1096
+ };
1097
+ });
1098
+ });
1099
+ }
1100
+ /**
1101
+ * Local-server helper (not part of {@link TakuhonAssetStorage}): read an asset
1102
+ * for static delivery, or `null` when it is absent or the key escapes the
1103
+ * `assets/` directory. The traversal guard lives here so the delivery route
1104
+ * and the persistence methods share one notion of "where assets live".
1105
+ */
1106
+ readForServing(key) {
1107
+ const full = this.resolveKey(key);
1108
+ if (full === null) return null;
1109
+ let bytes;
1110
+ try {
1111
+ bytes = readFileSync4(full);
1112
+ } catch (err) {
1113
+ if (isNotFound2(err)) return null;
1114
+ throw err;
1115
+ }
1116
+ return { bytes, contentType: contentTypeForFile(full) };
1117
+ }
1118
+ get projectDir() {
1119
+ return dirname4(resolve2(this.profilePath));
1120
+ }
1121
+ /** Resolve an `assets/...` key to an absolute path, or null if out of bounds. */
1122
+ resolveKey(key) {
1123
+ const root = resolve2(this.projectDir, ASSET_DIRNAME);
1124
+ const full = resolve2(this.projectDir, key);
1125
+ if (full !== root && !full.startsWith(root + sep)) return null;
1126
+ return full;
1127
+ }
1128
+ absoluteUrl(path) {
1129
+ return this.publicBaseUrl !== "" ? `${this.publicBaseUrl}${path}` : path;
1130
+ }
1131
+ };
704
1132
 
705
1133
  // src/file-storage.ts
1134
+ import { createHash } from "crypto";
1135
+ import { readFileSync as readFileSync5, rmSync as rmSync3 } from "fs";
1136
+ import { ConflictError, NotFoundError as NotFoundError2 } from "@takuhon/core";
706
1137
  function sha256Hex(content) {
707
1138
  return createHash("sha256").update(content, "utf8").digest("hex");
708
1139
  }
709
- function isNotFound(err) {
1140
+ function isNotFound3(err) {
710
1141
  return typeof err === "object" && err !== null && err.code === "ENOENT";
711
1142
  }
712
1143
  function readMaybe(path) {
713
1144
  try {
714
- return readFileSync2(path, "utf8");
1145
+ return readFileSync5(path, "utf8");
715
1146
  } catch (err) {
716
- if (isNotFound(err)) return void 0;
1147
+ if (isNotFound3(err)) return void 0;
717
1148
  throw err;
718
1149
  }
719
1150
  }
@@ -733,7 +1164,7 @@ var FileStorage = class {
733
1164
  return Promise.resolve().then(() => {
734
1165
  const raw = readMaybe(this.path);
735
1166
  if (raw === void 0) {
736
- throw new NotFoundError("No profile is stored yet.");
1167
+ throw new NotFoundError2("No profile is stored yet.");
737
1168
  }
738
1169
  let data;
739
1170
  try {
@@ -780,13 +1211,13 @@ var FileStorage = class {
780
1211
  content: current,
781
1212
  name: (withMillis) => preAdminSaveName(stamp, withMillis)
782
1213
  });
783
- rmSync2(this.path, { force: true });
1214
+ rmSync3(this.path, { force: true });
784
1215
  });
785
1216
  }
786
1217
  };
787
1218
 
788
1219
  // src/admin-command.ts
789
- var DEFAULT_PATH2 = "takuhon.json";
1220
+ var DEFAULT_PATH3 = "takuhon.json";
790
1221
  var DEFAULT_PORT2 = 4322;
791
1222
  var USAGE2 = `Usage: takuhon admin [path] [--port <n>] [--base-url <url>]
792
1223
 
@@ -813,9 +1244,9 @@ var CONTENT_TYPES = {
813
1244
  ".map": "application/json; charset=utf-8"
814
1245
  };
815
1246
  function contentTypeFor(file) {
816
- return CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream";
1247
+ return CONTENT_TYPES[extname2(file).toLowerCase()] ?? "application/octet-stream";
817
1248
  }
818
- function isNotFound2(err) {
1249
+ function isNotFound4(err) {
819
1250
  return typeof err === "object" && err !== null && err.code === "ENOENT";
820
1251
  }
821
1252
  function localAdminHeaders() {
@@ -828,7 +1259,15 @@ function localAdminHeaders() {
828
1259
  return headers;
829
1260
  }
830
1261
  var plain = (status, body) => new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
831
- function serveAdminBundle(bundleDir, method, pathname) {
1262
+ function escapeHtmlAttr(value) {
1263
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1264
+ }
1265
+ function injectLocalToken(html, token) {
1266
+ const meta = `<meta name="takuhon-local-token" content="${escapeHtmlAttr(token)}" />`;
1267
+ if (html.includes("</head>")) return html.replace("</head>", `${meta}</head>`);
1268
+ return meta + html;
1269
+ }
1270
+ function serveAdminBundle(bundleDir, method, pathname, token) {
832
1271
  if (method !== "GET" && method !== "HEAD") return plain(405, "Method Not Allowed\n");
833
1272
  const rest = pathname.slice("/admin".length);
834
1273
  let rel;
@@ -838,34 +1277,57 @@ function serveAdminBundle(bundleDir, method, pathname) {
838
1277
  rel = "";
839
1278
  }
840
1279
  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");
1280
+ const root = resolve3(bundleDir);
1281
+ let full = resolve3(root, rel);
1282
+ if (full !== root && !full.startsWith(root + sep2)) return plain(403, "Forbidden\n");
844
1283
  let body;
845
1284
  try {
846
- body = readFileSync3(full);
1285
+ body = readFileSync6(full);
847
1286
  } catch (err) {
848
- if (!isNotFound2(err)) throw err;
849
- if (extname(rel) !== "") return plain(404, "Not Found\n");
850
- full = join2(root, "index.html");
1287
+ if (!isNotFound4(err)) throw err;
1288
+ if (extname2(rel) !== "") return plain(404, "Not Found\n");
1289
+ full = join5(root, "index.html");
851
1290
  try {
852
- body = readFileSync3(full);
1291
+ body = readFileSync6(full);
853
1292
  } catch {
854
1293
  return plain(404, "Not Found\n");
855
1294
  }
856
1295
  }
1296
+ const contentType2 = contentTypeFor(full);
857
1297
  const headers = new Headers(localAdminHeaders());
858
- headers.set("content-type", contentTypeFor(full));
1298
+ headers.set("content-type", contentType2);
1299
+ if (contentType2.startsWith("text/html")) {
1300
+ const injected = injectLocalToken(body.toString("utf8"), token);
1301
+ return new Response(method === "HEAD" ? null : injected, { status: 200, headers });
1302
+ }
859
1303
  return new Response(method === "HEAD" ? null : body, { status: 200, headers });
860
1304
  }
1305
+ function serveLocalAsset(assetStorage, method, pathname) {
1306
+ if (method !== "GET" && method !== "HEAD") return plain(405, "Method Not Allowed\n");
1307
+ let key;
1308
+ try {
1309
+ key = decodeURIComponent(pathname.slice(1));
1310
+ } catch {
1311
+ return plain(400, "Bad Request\n");
1312
+ }
1313
+ const asset = assetStorage.readForServing(key);
1314
+ if (asset === null) return plain(404, "Not Found\n");
1315
+ const headers = new Headers();
1316
+ headers.set("content-type", asset.contentType);
1317
+ headers.set("x-content-type-options", "nosniff");
1318
+ headers.set("cache-control", "public, max-age=31536000, immutable");
1319
+ return new Response(method === "HEAD" ? null : asset.bytes, { status: 200, headers });
1320
+ }
861
1321
  function createAdminApp(opts) {
862
1322
  const bundleDir = opts.bundleDir ?? resolveAdminBundleDir();
863
1323
  const storage = new FileStorage(opts.path);
1324
+ const assetStorage = new FileTakuhonAssetStorage(opts.path, { publicBaseUrl: opts.assetBaseUrl });
864
1325
  const app = new Hono();
865
1326
  app.route(
866
1327
  "/api/admin",
867
1328
  createAdminApiApp({
868
1329
  storage,
1330
+ assetStorage,
869
1331
  getAdminToken: () => opts.token,
870
1332
  // Loopback, same-origin SPA: no Origin allowlist needed (empty = skip).
871
1333
  getAdminOrigins: () => [],
@@ -876,7 +1338,10 @@ function createAdminApp(opts) {
876
1338
  app.all("*", (c) => {
877
1339
  const { method, path } = c.req;
878
1340
  if (path === "/admin" || path.startsWith("/admin/")) {
879
- return serveAdminBundle(bundleDir, method, path);
1341
+ return serveAdminBundle(bundleDir, method, path, opts.token);
1342
+ }
1343
+ if (path.startsWith("/assets/")) {
1344
+ return serveLocalAsset(assetStorage, method, path);
880
1345
  }
881
1346
  const state = loadSiteState(opts.path, opts.baseUrl);
882
1347
  const res = handleRequest(method, path, state);
@@ -935,7 +1400,7 @@ function parseArgs2(args) {
935
1400
  if (baseUrl !== void 0 && !isHttpUrl2(baseUrl)) {
936
1401
  return { error: "takuhon: `--base-url` must be an absolute http(s) URL." };
937
1402
  }
938
- return { path: path ?? DEFAULT_PATH2, port, baseUrl: baseUrl?.replace(/\/+$/, "") };
1403
+ return { path: path ?? DEFAULT_PATH3, port, baseUrl: baseUrl?.replace(/\/+$/, "") };
939
1404
  }
940
1405
  function isHttpUrl2(value) {
941
1406
  try {
@@ -959,8 +1424,13 @@ Run \`takuhon admin --help\` for usage.
959
1424
  `);
960
1425
  return 2;
961
1426
  }
962
- const token = deps.token ?? randomBytes(32).toString("base64url");
963
- const app = createAdminApp({ path: parsed.path, token, baseUrl: parsed.baseUrl });
1427
+ const token = deps.token ?? randomBytes2(32).toString("base64url");
1428
+ const app = createAdminApp({
1429
+ path: parsed.path,
1430
+ token,
1431
+ baseUrl: parsed.baseUrl,
1432
+ assetBaseUrl: `http://127.0.0.1:${String(parsed.port)}`
1433
+ });
964
1434
  return await new Promise((resolvePromise) => {
965
1435
  let closing = false;
966
1436
  function shutdown() {
@@ -997,10 +1467,10 @@ Run \`takuhon admin --help\` for usage.
997
1467
  }
998
1468
 
999
1469
  // src/build-command.ts
1000
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync4 } from "fs";
1001
- import { dirname as dirname2, join as join3 } from "path";
1470
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync7 } from "fs";
1471
+ import { dirname as dirname5, join as join6 } from "path";
1002
1472
  import { applyPublicPrivacyFilter as applyPublicPrivacyFilter2, normalize as normalize2, validate as validate2 } from "@takuhon/core";
1003
- var DEFAULT_PATH3 = "takuhon.json";
1473
+ var DEFAULT_PATH4 = "takuhon.json";
1004
1474
  var DEFAULT_OUTPUT = "dist";
1005
1475
  var USAGE3 = `Usage: takuhon build [path] [--output <dir>] [--base-url <url>]
1006
1476
 
@@ -1077,7 +1547,7 @@ function parseArgs3(args) {
1077
1547
  return { error: "takuhon: `--base-url` must be an absolute http(s) URL." };
1078
1548
  }
1079
1549
  return {
1080
- path: path ?? DEFAULT_PATH3,
1550
+ path: path ?? DEFAULT_PATH4,
1081
1551
  output: output ?? DEFAULT_OUTPUT,
1082
1552
  // Drop any trailing slash so URL joins are predictable.
1083
1553
  baseUrl: baseUrl?.replace(/\/+$/, "")
@@ -1095,7 +1565,7 @@ function buildSite(parsed) {
1095
1565
  const { path, output, baseUrl } = parsed;
1096
1566
  let raw;
1097
1567
  try {
1098
- raw = readFileSync4(path, "utf8");
1568
+ raw = readFileSync7(path, "utf8");
1099
1569
  } catch {
1100
1570
  return {
1101
1571
  code: 2,
@@ -1124,11 +1594,12 @@ ${lines.join("\n")}
1124
1594
  };
1125
1595
  }
1126
1596
  const filtered = applyPublicPrivacyFilter2(normalize2(result.data));
1597
+ const activitySnapshot = filtered.settings.activity?.enabled === true ? readActivitySnapshotSync(path) : null;
1127
1598
  const written = [];
1128
1599
  try {
1129
- for (const page of generateSite(filtered, { baseUrl })) {
1130
- const outFile = join3(output, page.file);
1131
- mkdirSync2(dirname2(outFile), { recursive: true });
1600
+ for (const page of generateSite(filtered, { baseUrl, activitySnapshot })) {
1601
+ const outFile = join6(output, page.file);
1602
+ mkdirSync3(dirname5(outFile), { recursive: true });
1132
1603
  writeFileAtomic(outFile, page.html);
1133
1604
  written.push(outFile);
1134
1605
  }
@@ -1148,10 +1619,10 @@ ${summary}
1148
1619
  }
1149
1620
 
1150
1621
  // src/export-command.ts
1151
- import { readFileSync as readFileSync5 } from "fs";
1152
- import { resolve as resolve2 } from "path";
1622
+ import { readFileSync as readFileSync8 } from "fs";
1623
+ import { resolve as resolve4 } from "path";
1153
1624
  import { exportTakuhon, validate as validate3 } from "@takuhon/core";
1154
- var DEFAULT_PATH4 = "takuhon.json";
1625
+ var DEFAULT_PATH5 = "takuhon.json";
1155
1626
  var USAGE4 = `Usage: takuhon export [path] [--output <file>]
1156
1627
 
1157
1628
  Serialise a takuhon.json into its transport form and print it to stdout, or
@@ -1215,11 +1686,11 @@ function parseArgs4(args) {
1215
1686
  }
1216
1687
  path = arg;
1217
1688
  }
1218
- return { path: path ?? DEFAULT_PATH4, output };
1689
+ return { path: path ?? DEFAULT_PATH5, output };
1219
1690
  }
1220
1691
  function exportFile(parsed) {
1221
1692
  const { path, output } = parsed;
1222
- if (output !== void 0 && resolve2(output) === resolve2(path)) {
1693
+ if (output !== void 0 && resolve4(output) === resolve4(path)) {
1223
1694
  return {
1224
1695
  code: 2,
1225
1696
  stdout: "",
@@ -1230,7 +1701,7 @@ Omit --output to print to stdout, or choose a different file.
1230
1701
  }
1231
1702
  let raw;
1232
1703
  try {
1233
- raw = readFileSync5(path, "utf8");
1704
+ raw = readFileSync8(path, "utf8");
1234
1705
  } catch {
1235
1706
  return {
1236
1707
  code: 2,
@@ -1275,7 +1746,7 @@ ${lines.join("\n")}
1275
1746
  }
1276
1747
 
1277
1748
  // src/import-command.ts
1278
- import { readFileSync as readFileSync6 } from "fs";
1749
+ import { readFileSync as readFileSync9 } from "fs";
1279
1750
  import {
1280
1751
  ImportError,
1281
1752
  MigrationError,
@@ -1283,7 +1754,7 @@ import {
1283
1754
  importTakuhon,
1284
1755
  migrateTakuhon
1285
1756
  } from "@takuhon/core";
1286
- var DEFAULT_PATH5 = "takuhon.json";
1757
+ var DEFAULT_PATH6 = "takuhon.json";
1287
1758
  var USAGE5 = `Usage: takuhon import <file> [path]
1288
1759
 
1289
1760
  Load a previously exported profile from <file> into a local takuhon.json,
@@ -1327,13 +1798,13 @@ function parseArgs5(args) {
1327
1798
  if (positionals.length > 2) {
1328
1799
  return { error: "takuhon: `import` takes at most an input <file> and a target path." };
1329
1800
  }
1330
- return { file: positionals[0], path: positionals[1] ?? DEFAULT_PATH5 };
1801
+ return { file: positionals[0], path: positionals[1] ?? DEFAULT_PATH6 };
1331
1802
  }
1332
1803
  function importFile(parsed, now) {
1333
1804
  const { file, path } = parsed;
1334
1805
  let raw;
1335
1806
  try {
1336
- raw = readFileSync6(file, "utf8");
1807
+ raw = readFileSync9(file, "utf8");
1337
1808
  } catch {
1338
1809
  return { code: 2, stdout: "", stderr: `takuhon: cannot read '${file}'.
1339
1810
  ` };
@@ -1398,9 +1869,9 @@ ${lines2.join("\n")}` : ".";
1398
1869
  }
1399
1870
  let currentRaw;
1400
1871
  try {
1401
- currentRaw = readFileSync6(path, "utf8");
1872
+ currentRaw = readFileSync9(path, "utf8");
1402
1873
  } catch (error) {
1403
- if (!isNotFound3(error)) {
1874
+ if (!isNotFound5(error)) {
1404
1875
  return { code: 2, stdout: "", stderr: `takuhon: cannot read current profile '${path}'.
1405
1876
  ` };
1406
1877
  }
@@ -1439,13 +1910,13 @@ ${lines2.join("\n")}` : ".";
1439
1910
  return { code: 0, stdout: `${lines.join("\n")}
1440
1911
  `, stderr: "" };
1441
1912
  }
1442
- function isNotFound3(error) {
1913
+ function isNotFound5(error) {
1443
1914
  return typeof error === "object" && error !== null && error.code === "ENOENT";
1444
1915
  }
1445
1916
 
1446
1917
  // src/migrate-command.ts
1447
- import { readFileSync as readFileSync7 } from "fs";
1448
- import { resolve as resolve3 } from "path";
1918
+ import { readFileSync as readFileSync10 } from "fs";
1919
+ import { resolve as resolve5 } from "path";
1449
1920
  import {
1450
1921
  MigrationError as MigrationError2,
1451
1922
  SCHEMA_VERSION as SCHEMA_VERSION2,
@@ -1453,7 +1924,7 @@ import {
1453
1924
  migrateTakuhon as migrateTakuhon2,
1454
1925
  validate as validate4
1455
1926
  } from "@takuhon/core";
1456
- var DEFAULT_PATH6 = "takuhon.json";
1927
+ var DEFAULT_PATH7 = "takuhon.json";
1457
1928
  var USAGE6 = `Usage: takuhon migrate [path] [--to <version>] [--out <file>] [--dry-run]
1458
1929
 
1459
1930
  Forward-migrate a takuhon.json to a newer schema version. The source version
@@ -1534,13 +2005,13 @@ function parseArgs6(args) {
1534
2005
  error: `takuhon: unsupported --to version "${target}". Supported: ${SUPPORTED_SCHEMA_VERSIONS.join(", ")}.`
1535
2006
  };
1536
2007
  }
1537
- return { path: path ?? DEFAULT_PATH6, to: target, out, dryRun };
2008
+ return { path: path ?? DEFAULT_PATH7, to: target, out, dryRun };
1538
2009
  }
1539
2010
  function migrateFile(parsed, now) {
1540
2011
  const { path, to: target, out, dryRun } = parsed;
1541
2012
  let raw;
1542
2013
  try {
1543
- raw = readFileSync7(path, "utf8");
2014
+ raw = readFileSync10(path, "utf8");
1544
2015
  } catch {
1545
2016
  return {
1546
2017
  code: 2,
@@ -1575,7 +2046,7 @@ function migrateFile(parsed, now) {
1575
2046
  };
1576
2047
  }
1577
2048
  const writeTarget = out ?? path;
1578
- const inPlace = resolve3(writeTarget) === resolve3(path);
2049
+ const inPlace = resolve5(writeTarget) === resolve5(path);
1579
2050
  let migrated;
1580
2051
  try {
1581
2052
  migrated = migrateTakuhon2(data, target);
@@ -1653,11 +2124,119 @@ ${lines2.join("\n")}
1653
2124
  `, stderr: "" };
1654
2125
  }
1655
2126
 
2127
+ // src/refresh-admin-command.ts
2128
+ import { readFileSync as readFileSync11 } from "fs";
2129
+ import { cp, mkdtemp, readdir, rename, rm, stat } from "fs/promises";
2130
+ import { join as join7, resolve as resolve6 } from "path";
2131
+ var PROFILE_FILENAME = "takuhon.json";
2132
+ var USAGE7 = `Usage: takuhon admin update [path]
2133
+
2134
+ Refresh a project's ${ADMIN_DIST_DIRNAME}/ with the admin form-UI bundle shipped in
2135
+ this @takuhon/cli. Use it after upgrading @takuhon/cli to pick up a newer admin
2136
+ UI without re-scaffolding. With no path, refreshes the project in the current
2137
+ directory. The project must already have an ${ADMIN_DIST_DIRNAME}/ (created by
2138
+ create-takuhon); this command updates it, it does not create one.
2139
+
2140
+ Exit codes: 0 = refreshed, 2 = bad arguments / not a takuhon project /
2141
+ no ${ADMIN_DIST_DIRNAME}/ to refresh / copy failed.
2142
+ `;
2143
+ async function runRefreshAdmin(args = [], opts = {}) {
2144
+ if (args[0] === "--help" || args[0] === "-h") {
2145
+ return { code: 0, stdout: USAGE7, stderr: "" };
2146
+ }
2147
+ const unknownFlag = args.find((arg) => arg.startsWith("-"));
2148
+ if (unknownFlag !== void 0) {
2149
+ return usageError(`unknown option '${unknownFlag}'.`);
2150
+ }
2151
+ if (args.length > 1) {
2152
+ return usageError("`admin update` takes at most one path argument.");
2153
+ }
2154
+ const displayDir = args[0] ?? ".";
2155
+ const projectDir = resolve6(args[0] ?? ".");
2156
+ try {
2157
+ await stat(join7(projectDir, PROFILE_FILENAME));
2158
+ } catch {
2159
+ return {
2160
+ code: 2,
2161
+ stdout: "",
2162
+ stderr: `takuhon: '${displayDir}' is not a takuhon project (no ${PROFILE_FILENAME} found).
2163
+ Run \`takuhon admin update\` from a project created by create-takuhon.
2164
+ `
2165
+ };
2166
+ }
2167
+ const adminDist = join7(projectDir, ADMIN_DIST_DIRNAME);
2168
+ const adminDistStat = await stat(adminDist).catch(() => void 0);
2169
+ if (adminDistStat === void 0) {
2170
+ return {
2171
+ code: 2,
2172
+ stdout: "",
2173
+ stderr: `takuhon: '${displayDir}' has no ${ADMIN_DIST_DIRNAME}/ directory to refresh.
2174
+ This project was not scaffolded with the admin form UI; create a new project with create-takuhon to add it.
2175
+ `
2176
+ };
2177
+ }
2178
+ if (!adminDistStat.isDirectory()) {
2179
+ return {
2180
+ code: 2,
2181
+ stdout: "",
2182
+ stderr: `takuhon: '${join7(displayDir, ADMIN_DIST_DIRNAME)}' exists but is not a directory.
2183
+ `
2184
+ };
2185
+ }
2186
+ const bundleDir = opts.bundleDir ?? resolveAdminBundleDir();
2187
+ let fileCount;
2188
+ let staged;
2189
+ try {
2190
+ fileCount = (await readdir(bundleDir, { recursive: true, withFileTypes: true })).filter(
2191
+ (entry) => entry.isFile()
2192
+ ).length;
2193
+ staged = await mkdtemp(join7(projectDir, `.${ADMIN_DIST_DIRNAME}-`));
2194
+ await cp(bundleDir, staged, { recursive: true });
2195
+ await rm(adminDist, { recursive: true, force: true });
2196
+ await rename(staged, adminDist);
2197
+ staged = void 0;
2198
+ } catch (err) {
2199
+ if (staged !== void 0) {
2200
+ await rm(staged, { recursive: true, force: true }).catch(() => void 0);
2201
+ }
2202
+ const code = isNodeErrnoException(err) ? ` (${err.code})` : "";
2203
+ return {
2204
+ code: 2,
2205
+ stdout: "",
2206
+ stderr: `takuhon: failed to refresh ${ADMIN_DIST_DIRNAME}/ from @takuhon/cli${code}. Reinstall or upgrade @takuhon/cli and try again.
2207
+ `
2208
+ };
2209
+ }
2210
+ const plural = fileCount === 1 ? "" : "s";
2211
+ return {
2212
+ code: 0,
2213
+ stdout: `Refreshed ${ADMIN_DIST_DIRNAME}/ from @takuhon/cli@${readVersion()} (${fileCount} file${plural}).
2214
+ `,
2215
+ stderr: ""
2216
+ };
2217
+ }
2218
+ function usageError(message) {
2219
+ return {
2220
+ code: 2,
2221
+ stdout: "",
2222
+ stderr: `takuhon: ${message}
2223
+ Run \`takuhon admin update --help\` for usage.
2224
+ `
2225
+ };
2226
+ }
2227
+ function readVersion() {
2228
+ const pkg2 = JSON.parse(readFileSync11(new URL("../package.json", import.meta.url), "utf8"));
2229
+ return pkg2.version;
2230
+ }
2231
+ function isNodeErrnoException(err) {
2232
+ return err instanceof Error && typeof err.code === "string";
2233
+ }
2234
+
1656
2235
  // src/restore-command.ts
1657
- import { readFileSync as readFileSync8 } from "fs";
2236
+ import { readFileSync as readFileSync12 } from "fs";
1658
2237
  import { validate as validate5 } from "@takuhon/core";
1659
- var DEFAULT_PATH7 = "takuhon.json";
1660
- var USAGE7 = `Usage: takuhon restore --from <backup> [path] [--yes]
2238
+ var DEFAULT_PATH8 = "takuhon.json";
2239
+ var USAGE8 = `Usage: takuhon restore --from <backup> [path] [--yes]
1661
2240
 
1662
2241
  Overwrite a profile with a previously saved backup. With no path, restores
1663
2242
  ./takuhon.json in the current working directory.
@@ -1674,7 +2253,7 @@ Exit codes: 0 = restored / aborted, 1 = backup failed validation,
1674
2253
  `;
1675
2254
  async function runRestore(args = [], deps = {}) {
1676
2255
  if (args[0] === "--help" || args[0] === "-h") {
1677
- return { code: 0, stdout: USAGE7, stderr: "" };
2256
+ return { code: 0, stdout: USAGE8, stderr: "" };
1678
2257
  }
1679
2258
  const parsed = parseArgs7(args);
1680
2259
  if ("error" in parsed) {
@@ -1723,13 +2302,13 @@ function parseArgs7(args) {
1723
2302
  if (from === void 0 || from.length === 0) {
1724
2303
  return { error: "takuhon: `restore` requires `--from <backup>`." };
1725
2304
  }
1726
- return { from, path: path ?? DEFAULT_PATH7, yes };
2305
+ return { from, path: path ?? DEFAULT_PATH8, yes };
1727
2306
  }
1728
2307
  async function restoreFile(parsed, now, confirm) {
1729
2308
  const { from, path, yes } = parsed;
1730
2309
  let backupRaw;
1731
2310
  try {
1732
- backupRaw = readFileSync8(from, "utf8");
2311
+ backupRaw = readFileSync12(from, "utf8");
1733
2312
  } catch {
1734
2313
  return { code: 2, stdout: "", stderr: `takuhon: cannot read backup '${from}'.
1735
2314
  ` };
@@ -1759,9 +2338,9 @@ ${lines2.join("\n")}
1759
2338
  }
1760
2339
  let currentRaw;
1761
2340
  try {
1762
- currentRaw = readFileSync8(path, "utf8");
2341
+ currentRaw = readFileSync12(path, "utf8");
1763
2342
  } catch (error) {
1764
- if (!isNotFound4(error)) {
2343
+ if (!isNotFound6(error)) {
1765
2344
  return { code: 2, stdout: "", stderr: `takuhon: cannot read current profile '${path}'.
1766
2345
  ` };
1767
2346
  }
@@ -1824,17 +2403,17 @@ function confirmationMessage(path, from, data, currentRaw, preRestorePath) {
1824
2403
  ${preNote}
1825
2404
  Continue? [y/N]`;
1826
2405
  }
1827
- function isNotFound4(error) {
2406
+ function isNotFound6(error) {
1828
2407
  return typeof error === "object" && error !== null && error.code === "ENOENT";
1829
2408
  }
1830
2409
 
1831
2410
  // src/sync-command.ts
1832
- import { readFileSync as readFileSync9 } from "fs";
2411
+ import { readFileSync as readFileSync13 } from "fs";
1833
2412
  import { validate as validate6 } from "@takuhon/core";
1834
- var DEFAULT_PATH8 = "takuhon.json";
2413
+ var DEFAULT_PATH9 = "takuhon.json";
1835
2414
  var ADMIN_PROFILE_PATH = "/api/admin/profile";
1836
2415
  var TOKEN_ENV = "TAKUHON_ADMIN_TOKEN";
1837
- var USAGE8 = `Usage: takuhon sync [path] --url <base-url> [--if-match <etag>] [--dry-run]
2416
+ var USAGE9 = `Usage: takuhon sync [path] --url <base-url> [--if-match <etag>] [--dry-run]
1838
2417
 
1839
2418
  Push a local takuhon.json to a deployed takuhon instance by calling its admin
1840
2419
  write endpoint (PUT <base-url>/api/admin/profile). With no path, syncs
@@ -1860,7 +2439,7 @@ unset / auth failure / network error / other non-success response.
1860
2439
  `;
1861
2440
  async function runSync(args = [], deps = {}) {
1862
2441
  if (args[0] === "--help" || args[0] === "-h") {
1863
- return { code: 0, stdout: USAGE8, stderr: "" };
2442
+ return { code: 0, stdout: USAGE9, stderr: "" };
1864
2443
  }
1865
2444
  const parsed = parseArgs8(args);
1866
2445
  if ("error" in parsed) {
@@ -1920,7 +2499,7 @@ function parseArgs8(args) {
1920
2499
  }
1921
2500
  const base = parseOrigin(url);
1922
2501
  if ("error" in base) return base;
1923
- return { path: path ?? DEFAULT_PATH8, url: base.origin, ifMatch, dryRun };
2502
+ return { path: path ?? DEFAULT_PATH9, url: base.origin, ifMatch, dryRun };
1924
2503
  }
1925
2504
  function parseOrigin(value) {
1926
2505
  let parsed;
@@ -1953,7 +2532,7 @@ async function syncProfile(parsed, deps) {
1953
2532
  const { path, url, ifMatch, dryRun } = parsed;
1954
2533
  let raw;
1955
2534
  try {
1956
- raw = readFileSync9(path, "utf8");
2535
+ raw = readFileSync13(path, "utf8");
1957
2536
  } catch {
1958
2537
  return { code: 2, stdout: "", stderr: `takuhon: cannot read '${path}'.
1959
2538
  ` };
@@ -2024,7 +2603,7 @@ Set it for this command only, e.g.:
2024
2603
  async function interpretResponse(res, target) {
2025
2604
  const { path, url, endpoint } = target;
2026
2605
  if (res.ok) {
2027
- const version = await readVersion(res);
2606
+ const version = await readVersion2(res);
2028
2607
  if (version === void 0) {
2029
2608
  return {
2030
2609
  code: 2,
@@ -2066,7 +2645,7 @@ ${lines.join("\n")}` : ".";
2066
2645
  return { code: 2, stdout: "", stderr: `takuhon: sync failed (${String(status)})${tail}
2067
2646
  ` };
2068
2647
  }
2069
- async function readVersion(res) {
2648
+ async function readVersion2(res) {
2070
2649
  try {
2071
2650
  const parsed = await res.json();
2072
2651
  const version = parsed.meta?.version;
@@ -2090,10 +2669,10 @@ async function readProblem(res) {
2090
2669
  }
2091
2670
 
2092
2671
  // src/validate-command.ts
2093
- import { readFileSync as readFileSync10 } from "fs";
2672
+ import { readFileSync as readFileSync14 } from "fs";
2094
2673
  import { validate as validate7 } from "@takuhon/core";
2095
- var DEFAULT_PATH9 = "takuhon.json";
2096
- var USAGE9 = `Usage: takuhon validate [path]
2674
+ var DEFAULT_PATH10 = "takuhon.json";
2675
+ var USAGE10 = `Usage: takuhon validate [path]
2097
2676
 
2098
2677
  Validate a takuhon.json against the takuhon schema. With no path, validates
2099
2678
  ./takuhon.json in the current working directory.
@@ -2102,7 +2681,7 @@ Exit codes: 0 = valid, 1 = invalid, 2 = file missing / unreadable / not JSON.
2102
2681
  `;
2103
2682
  function runValidate(args = []) {
2104
2683
  if (args[0] === "--help" || args[0] === "-h") {
2105
- return { code: 0, stdout: USAGE9, stderr: "" };
2684
+ return { code: 0, stdout: USAGE10, stderr: "" };
2106
2685
  }
2107
2686
  if (args.length > 1) {
2108
2687
  return {
@@ -2114,10 +2693,10 @@ function runValidate(args = []) {
2114
2693
  return validateFile(args[0]);
2115
2694
  }
2116
2695
  function validateFile(pathArg) {
2117
- const target = pathArg ?? DEFAULT_PATH9;
2696
+ const target = pathArg ?? DEFAULT_PATH10;
2118
2697
  let raw;
2119
2698
  try {
2120
- raw = readFileSync10(target, "utf8");
2699
+ raw = readFileSync14(target, "utf8");
2121
2700
  } catch {
2122
2701
  return {
2123
2702
  code: 2,
@@ -2159,7 +2738,7 @@ ${lines.join("\n")}
2159
2738
  }
2160
2739
 
2161
2740
  // src/index.ts
2162
- var pkg = JSON.parse(readFileSync11(new URL("../package.json", import.meta.url), "utf8"));
2741
+ var pkg = JSON.parse(readFileSync15(new URL("../package.json", import.meta.url), "utf8"));
2163
2742
  var VERSION = pkg.version;
2164
2743
  var HELP = `takuhon ${VERSION}
2165
2744
 
@@ -2189,10 +2768,18 @@ Commands:
2189
2768
  the form UI at /admin (writes takuhon.json, backs up
2190
2769
  first) with a preview at / (default port: 4322). Binds
2191
2770
  127.0.0.1; prints a per-run token to paste into the form.
2771
+ takuhon admin update [path] Refresh a project's admin-dist/ with the admin form UI
2772
+ bundled in this @takuhon/cli (use after upgrading the
2773
+ CLI). Updates an existing admin-dist/ only.
2192
2774
  takuhon sync [path] --url <url> Push a takuhon.json to a deployment's admin API
2193
2775
  (PUT <url>/api/admin/profile). Reads the admin token
2194
2776
  from TAKUHON_ADMIN_TOKEN. --if-match <etag> opts into
2195
2777
  optimistic locking; --dry-run previews without sending.
2778
+ takuhon activity sync [path] Fetch the GitHub / WakaTime activity configured in
2779
+ settings.activity and store it as activity.json beside
2780
+ the profile. Secrets come from the environment only:
2781
+ TAKUHON_GITHUB_TOKEN (optional) / TAKUHON_WAKATIME_KEY.
2782
+ takuhon activity show [path] Print the stored activity snapshot (activity.json).
2196
2783
 
2197
2784
  Scaffolding a new profile project:
2198
2785
  npx create-takuhon my-profile
@@ -2225,6 +2812,9 @@ async function main(argv) {
2225
2812
  return runDev(argv.slice(1));
2226
2813
  }
2227
2814
  if (first === "admin") {
2815
+ if (argv[1] === "update") {
2816
+ return emit(await runRefreshAdmin(argv.slice(2)));
2817
+ }
2228
2818
  return runAdmin(argv.slice(1));
2229
2819
  }
2230
2820
  if (first === "import") {
@@ -2237,6 +2827,26 @@ async function main(argv) {
2237
2827
  if (first === "sync") {
2238
2828
  return emit(await runSync(argv.slice(1)));
2239
2829
  }
2830
+ if (first === "activity") {
2831
+ const sub = argv[1];
2832
+ if (sub === "sync") {
2833
+ return emit(await runActivitySync(argv.slice(2)));
2834
+ }
2835
+ if (sub === "show") {
2836
+ return emit(await runActivityShow(argv.slice(2)));
2837
+ }
2838
+ 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";
2839
+ if (sub === "--help" || sub === "-h") {
2840
+ process.stdout.write(usage);
2841
+ return 0;
2842
+ }
2843
+ process.stderr.write(
2844
+ sub === void 0 ? `takuhon: \`activity\` requires a subcommand.
2845
+ ${usage}` : `takuhon: unknown subcommand \`${sub}\` for \`activity\`.
2846
+ ${usage}`
2847
+ );
2848
+ return 2;
2849
+ }
2240
2850
  process.stderr.write(
2241
2851
  `takuhon: unknown command '${first}'
2242
2852
  Run \`takuhon --help\` for usage. For scaffolding a new project, use \`create-takuhon\`.