@takuhon/cli 0.10.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,15 +1,24 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ ADMIN_DIST_DIRNAME,
4
+ resolveAdminBundleDir
5
+ } from "./chunk-XMSWT5NY.js";
2
6
 
3
7
  // src/index.ts
4
- import { readFileSync as readFileSync9, realpathSync } from "fs";
8
+ import { readFileSync as readFileSync15, realpathSync } from "fs";
5
9
  import { stdin, stdout } from "process";
6
10
  import { createInterface } from "readline/promises";
7
11
  import { fileURLToPath } from "url";
8
12
 
9
- // src/build-command.ts
10
- import { mkdirSync as mkdirSync2, readFileSync } from "fs";
11
- import { dirname as dirname2, join as join2 } from "path";
12
- import { applyPublicPrivacyFilter, normalize, validate } from "@takuhon/core";
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";
13
22
 
14
23
  // src/backup.ts
15
24
  import { mkdirSync, renameSync, rmSync, writeFileSync } from "fs";
@@ -35,6 +44,9 @@ function preRestoreName(date, withMillis = false) {
35
44
  function preImportName(date, withMillis = false) {
36
45
  return `pre-import-${compactTimestamp(date, withMillis)}.json`;
37
46
  }
47
+ function preAdminSaveName(date, withMillis = false) {
48
+ return `pre-admin-${compactTimestamp(date, withMillis)}.json`;
49
+ }
38
50
  function backupDirFor(targetPath) {
39
51
  return join(dirname(targetPath), BACKUP_DIR_NAME);
40
52
  }
@@ -76,11 +88,273 @@ function writeFileAtomic(target, content) {
76
88
  }
77
89
  }
78
90
 
79
- // src/site.ts
80
- import { resolveLocale } from "@takuhon/core";
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
+
338
+ // src/admin-command.ts
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";
342
+ import { serve } from "@hono/node-server";
343
+ import {
344
+ adminAssetSecurityHeaders,
345
+ createAdminApiApp,
346
+ noopAuditLogger,
347
+ noopCachePurger
348
+ } from "@takuhon/api";
349
+ import { Hono } from "hono";
350
+
351
+ // src/dev-command.ts
352
+ import { readFileSync as readFileSync3 } from "fs";
353
+ import { createServer } from "http";
354
+ import { applyPublicPrivacyFilter, normalize, validate } from "@takuhon/core";
81
355
 
82
356
  // src/build-html.ts
83
- import { generateJsonLd } from "@takuhon/core";
357
+ import { generateJsonLd, renderActivitySvg } from "@takuhon/core";
84
358
  function escapeHtml(value) {
85
359
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
86
360
  }
@@ -116,6 +390,7 @@ ul{padding:0;margin:0;list-style:none}
116
390
  .rec blockquote{margin:0;padding-left:.9rem;border-left:3px solid var(--line)}
117
391
  .rec figcaption{color:var(--muted);font-size:.9rem;margin-top:.3rem}
118
392
  nav.locales{display:flex;gap:.75rem;margin-bottom:1rem;font-size:.9rem}
393
+ .activity svg{max-width:100%;height:auto}
119
394
  footer.powered{max-width:42rem;margin:0 auto;padding:1.5rem 1.25rem;color:var(--muted);font-size:.85rem}`;
120
395
  function dateRange(start, end, isCurrent) {
121
396
  const left = start ?? "";
@@ -203,6 +478,12 @@ function renderContact(contact) {
203
478
  if (items.length === 0) return "";
204
479
  return `<section><h2>Contact</h2><ul class="entries">${items.join("")}</ul></section>`;
205
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
+ }
206
487
  function renderJsonLdScript(data) {
207
488
  const payload = JSON.stringify(generateJsonLd(data));
208
489
  return `<script type="application/ld+json">${escapeJsonLd(payload)}</script>`;
@@ -254,6 +535,7 @@ function renderProfileHtml(input) {
254
535
  }))
255
536
  ),
256
537
  renderSkills(d.skills),
538
+ renderActivity(input.activitySnapshot),
257
539
  entryList(
258
540
  "Education",
259
541
  d.education.map((e) => {
@@ -366,11 +648,13 @@ ${footer ? `${footer}
366
648
  }
367
649
 
368
650
  // src/site.ts
651
+ import { resolveLocale } from "@takuhon/core";
369
652
  function generateSite(profile, options = {}) {
370
653
  const { baseUrl } = options;
371
654
  const defaultLocale = profile.settings.defaultLocale;
372
655
  const locales = [.../* @__PURE__ */ new Set([defaultLocale, ...profile.settings.availableLocales])];
373
656
  const jsonLd = profile.settings.enableJsonLd !== false;
657
+ const activitySnapshot = profile.settings.activity?.enabled === true ? options.activitySnapshot ?? void 0 : void 0;
374
658
  return locales.map((locale) => {
375
659
  const localized = resolveLocale(profile, locale);
376
660
  const isDefault = locale === defaultLocale;
@@ -381,7 +665,14 @@ function generateSite(profile, options = {}) {
381
665
  }));
382
666
  const canonicalUrl = baseUrl ? absoluteUrl(baseUrl, locale, defaultLocale) : void 0;
383
667
  const alternates = baseUrl ? buildAlternates(baseUrl, locales, defaultLocale) : [];
384
- 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
+ });
385
676
  return {
386
677
  route: isDefault ? "/" : `/${locale}/`,
387
678
  file: isDefault ? "index.html" : `${locale}/index.html`,
@@ -410,208 +701,60 @@ function localeHref(from, to, defaultLocale) {
410
701
  return toRoot ? "../" : `../${to}/`;
411
702
  }
412
703
 
413
- // src/build-command.ts
414
- var DEFAULT_PATH = "takuhon.json";
415
- var DEFAULT_OUTPUT = "dist";
416
- var USAGE = `Usage: takuhon build [path] [--output <dir>] [--base-url <url>]
704
+ // src/dev-command.ts
705
+ var DEFAULT_PATH2 = "takuhon.json";
706
+ var DEFAULT_PORT = 4321;
707
+ var USAGE = `Usage: takuhon dev [path] [--port <n>] [--base-url <url>]
417
708
 
418
- Render a takuhon.json into a static site (one HTML page per locale, with
419
- build-time Schema.org JSON-LD). With no path, builds ./takuhon.json.
709
+ Serve a takuhon.json as a local static preview (one page per locale) \u2014 the same
710
+ surface \`takuhon build\` produces. With no path, serves ./takuhon.json. The file
711
+ is re-read and re-rendered on every request, so edit it and reload the browser
712
+ to see changes. Stop with Ctrl-C.
420
713
 
421
714
  Options:
422
- --output <dir> Output directory (default: ${DEFAULT_OUTPUT}). The default
423
- locale is written to <dir>/index.html and each other locale
424
- to <dir>/<locale>/index.html.
715
+ --port <n> Port to listen on (default: ${DEFAULT_PORT}).
425
716
  --base-url <url> Site origin (e.g. https://me.example). Enables absolute
426
717
  canonical and hreflang links; without it those are omitted.
427
718
 
428
- The public privacy filter is applied (meta.privacy is honoured). Asset URLs are
429
- referenced as-is and are not copied. The output directory is written into, not
430
- cleaned \u2014 use a dedicated/empty directory so stale pages do not linger.
719
+ The public privacy filter is applied (meta.privacy is honoured). An invalid
720
+ takuhon.json is served as an error page so you can fix it and reload.
431
721
 
432
- Exit codes: 0 = built, 1 = source is not a valid profile,
433
- 2 = bad arguments / file missing / unreadable / not JSON / write failed.
722
+ Exit codes: 0 = served then stopped, 2 = bad arguments / file missing /
723
+ unreadable / port in use.
434
724
  `;
435
- function runBuild(args = []) {
436
- if (args[0] === "--help" || args[0] === "-h") {
437
- return { code: 0, stdout: USAGE, stderr: "" };
438
- }
439
- const parsed = parseArgs(args);
440
- if ("error" in parsed) {
441
- return {
442
- code: 2,
443
- stdout: "",
444
- stderr: `${parsed.error}
445
- Run \`takuhon build --help\` for usage.
446
- `
447
- };
448
- }
449
- return buildSite(parsed);
450
- }
451
- function parseArgs(args) {
452
- let path;
453
- let output;
454
- let baseUrl;
455
- for (let i = 0; i < args.length; i++) {
456
- const arg = args[i];
457
- if (arg === "--output" || arg === "--base-url") {
458
- const value = args[i + 1];
459
- if (value === void 0 || value === "" || value.startsWith("-")) {
460
- return { error: `takuhon: \`${arg}\` requires a value.` };
461
- }
462
- if (arg === "--output") output = value;
463
- else baseUrl = value;
464
- i++;
465
- continue;
466
- }
467
- if (arg.startsWith("--output=")) {
468
- const value = arg.slice("--output=".length);
469
- if (value === "") return { error: "takuhon: `--output` requires a value." };
470
- output = value;
471
- continue;
472
- }
473
- if (arg.startsWith("--base-url=")) {
474
- const value = arg.slice("--base-url=".length);
475
- if (value === "") return { error: "takuhon: `--base-url` requires a value." };
476
- baseUrl = value;
477
- continue;
478
- }
479
- if (arg.startsWith("-")) {
480
- return { error: `takuhon: unknown option \`${arg}\` for \`build\`.` };
481
- }
482
- if (path !== void 0) {
483
- return { error: "takuhon: `build` takes at most one path argument." };
484
- }
485
- path = arg;
486
- }
487
- if (baseUrl !== void 0 && !isHttpUrl(baseUrl)) {
488
- return { error: "takuhon: `--base-url` must be an absolute http(s) URL." };
489
- }
490
- return {
491
- path: path ?? DEFAULT_PATH,
492
- output: output ?? DEFAULT_OUTPUT,
493
- // Drop any trailing slash so URL joins are predictable.
494
- baseUrl: baseUrl?.replace(/\/+$/, "")
495
- };
496
- }
497
- function isHttpUrl(value) {
498
- try {
499
- const url = new URL(value);
500
- return url.protocol === "http:" || url.protocol === "https:";
501
- } catch {
502
- return false;
503
- }
504
- }
505
- function buildSite(parsed) {
506
- const { path, output, baseUrl } = parsed;
725
+ function loadSiteState(path, baseUrl) {
507
726
  let raw;
508
727
  try {
509
- raw = readFileSync(path, "utf8");
728
+ raw = readFileSync3(path, "utf8");
510
729
  } catch {
511
- return {
512
- code: 2,
513
- stdout: "",
514
- stderr: `takuhon: cannot read '${path}'. Pass a path, or run from a directory containing a takuhon.json.
515
- `
516
- };
730
+ return { ok: false, status: 500, message: `cannot read '${path}'.` };
517
731
  }
518
732
  let data;
519
733
  try {
520
734
  data = JSON.parse(raw);
521
735
  } catch (error) {
522
736
  const detail = error instanceof Error ? error.message : String(error);
523
- return { code: 2, stdout: "", stderr: `takuhon: '${path}' is not valid JSON: ${detail}
524
- ` };
737
+ return { ok: false, status: 500, message: `'${path}' is not valid JSON: ${detail}` };
525
738
  }
526
739
  const result = validate(data);
527
740
  if (!result.ok) {
528
741
  const lines = result.errors.map((e) => ` ${e.pointer || "/"}: ${e.message}`);
529
742
  return {
530
- code: 1,
531
- stdout: "",
532
- stderr: `takuhon: '${path}' is not a valid takuhon profile; refusing to build:
533
- ${lines.join("\n")}
534
- `
743
+ ok: false,
744
+ status: 500,
745
+ message: `'${path}' is not a valid takuhon profile:
746
+ ${lines.join("\n")}`
535
747
  };
536
748
  }
537
749
  const filtered = applyPublicPrivacyFilter(normalize(result.data));
538
- const written = [];
539
- try {
540
- for (const page of generateSite(filtered, { baseUrl })) {
541
- const outFile = join2(output, page.file);
542
- mkdirSync2(dirname2(outFile), { recursive: true });
543
- writeFileAtomic(outFile, page.html);
544
- written.push(outFile);
545
- }
546
- } catch (error) {
547
- const detail = error instanceof Error ? error.message : String(error);
548
- return { code: 2, stdout: "", stderr: `takuhon: failed to write the site: ${detail}
549
- ` };
550
- }
551
- const summary = written.map((w) => ` ${w}`).join("\n");
552
- return {
553
- code: 0,
554
- stdout: `built ${written.length} page${written.length === 1 ? "" : "s"} from ${path}:
555
- ${summary}
556
- `,
557
- stderr: ""
558
- };
559
- }
560
-
561
- // src/dev-command.ts
562
- import { readFileSync as readFileSync2 } from "fs";
563
- import { createServer } from "http";
564
- import { applyPublicPrivacyFilter as applyPublicPrivacyFilter2, normalize as normalize2, validate as validate2 } from "@takuhon/core";
565
- var DEFAULT_PATH2 = "takuhon.json";
566
- var DEFAULT_PORT = 4321;
567
- var USAGE2 = `Usage: takuhon dev [path] [--port <n>] [--base-url <url>]
568
-
569
- Serve a takuhon.json as a local static preview (one page per locale) \u2014 the same
570
- surface \`takuhon build\` produces. With no path, serves ./takuhon.json. The file
571
- is re-read and re-rendered on every request, so edit it and reload the browser
572
- to see changes. Stop with Ctrl-C.
573
-
574
- Options:
575
- --port <n> Port to listen on (default: ${DEFAULT_PORT}).
576
- --base-url <url> Site origin (e.g. https://me.example). Enables absolute
577
- canonical and hreflang links; without it those are omitted.
578
-
579
- The public privacy filter is applied (meta.privacy is honoured). An invalid
580
- takuhon.json is served as an error page so you can fix it and reload.
581
-
582
- Exit codes: 0 = served then stopped, 2 = bad arguments / file missing /
583
- unreadable / port in use.
584
- `;
585
- function loadSiteState(path, baseUrl) {
586
- let raw;
587
- try {
588
- raw = readFileSync2(path, "utf8");
589
- } catch {
590
- return { ok: false, status: 500, message: `cannot read '${path}'.` };
591
- }
592
- let data;
593
- try {
594
- data = JSON.parse(raw);
595
- } catch (error) {
596
- const detail = error instanceof Error ? error.message : String(error);
597
- return { ok: false, status: 500, message: `'${path}' is not valid JSON: ${detail}` };
598
- }
599
- const result = validate2(data);
600
- if (!result.ok) {
601
- const lines = result.errors.map((e) => ` ${e.pointer || "/"}: ${e.message}`);
602
- return {
603
- ok: false,
604
- status: 500,
605
- message: `'${path}' is not a valid takuhon profile:
606
- ${lines.join("\n")}`
607
- };
608
- }
609
- const filtered = applyPublicPrivacyFilter2(normalize2(result.data));
610
- const pages = new Map(generateSite(filtered, { baseUrl }).map((p) => [p.route, p.html]));
611
- return { ok: true, pages };
612
- }
613
- function resolveRoute(urlPath) {
614
- let p = urlPath;
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
+ );
754
+ return { ok: true, pages };
755
+ }
756
+ function resolveRoute(urlPath) {
757
+ let p = urlPath;
615
758
  try {
616
759
  p = decodeURIComponent(urlPath);
617
760
  } catch {
@@ -661,10 +804,10 @@ async function runDev(args = [], deps = {}) {
661
804
  const out = deps.stdout ?? ((text) => void process.stdout.write(text));
662
805
  const err = deps.stderr ?? ((text) => void process.stderr.write(text));
663
806
  if (args[0] === "--help" || args[0] === "-h") {
664
- out(USAGE2);
807
+ out(USAGE);
665
808
  return 0;
666
809
  }
667
- const parsed = parseArgs2(args);
810
+ const parsed = parseArgs(args);
668
811
  if ("error" in parsed) {
669
812
  err(`${parsed.error}
670
813
  Run \`takuhon dev --help\` for usage.
@@ -672,7 +815,7 @@ Run \`takuhon dev --help\` for usage.
672
815
  return 2;
673
816
  }
674
817
  try {
675
- readFileSync2(parsed.path, "utf8");
818
+ readFileSync3(parsed.path, "utf8");
676
819
  } catch {
677
820
  err(
678
821
  `takuhon: cannot read '${parsed.path}'. Pass a path, or run from a directory containing a takuhon.json.
@@ -681,14 +824,14 @@ Run \`takuhon dev --help\` for usage.
681
824
  return 2;
682
825
  }
683
826
  const server = createDevServer({ path: parsed.path, baseUrl: parsed.baseUrl });
684
- return await new Promise((resolve3) => {
827
+ return await new Promise((resolve7) => {
685
828
  let closing = false;
686
829
  const shutdown = () => {
687
830
  if (closing) return;
688
831
  closing = true;
689
832
  process.removeListener("SIGINT", shutdown);
690
833
  process.removeListener("SIGTERM", shutdown);
691
- server.close(() => resolve3(0));
834
+ server.close(() => resolve7(0));
692
835
  server.closeAllConnections();
693
836
  };
694
837
  server.once("error", (error) => {
@@ -699,7 +842,7 @@ Run \`takuhon dev --help\` for usage.
699
842
  err(`takuhon: ${error.message}
700
843
  `);
701
844
  }
702
- resolve3(2);
845
+ resolve7(2);
703
846
  });
704
847
  server.listen(parsed.port, "127.0.0.1", () => {
705
848
  out(
@@ -711,33 +854,679 @@ Run \`takuhon dev --help\` for usage.
711
854
  err(
712
855
  `takuhon dev: ${parsed.path} is not a valid profile yet; the preview will show the error until it is fixed.
713
856
  `
714
- );
715
- }
716
- process.once("SIGINT", shutdown);
717
- process.once("SIGTERM", shutdown);
718
- });
719
- });
857
+ );
858
+ }
859
+ process.once("SIGINT", shutdown);
860
+ process.once("SIGTERM", shutdown);
861
+ });
862
+ });
863
+ }
864
+ function parseArgs(args) {
865
+ let path;
866
+ let portRaw;
867
+ let baseUrl;
868
+ for (let i = 0; i < args.length; i++) {
869
+ const arg = args[i];
870
+ if (arg === "--port" || arg === "--base-url") {
871
+ const value = args[i + 1];
872
+ if (value === void 0 || value === "" || value.startsWith("-")) {
873
+ return { error: `takuhon: \`${arg}\` requires a value.` };
874
+ }
875
+ if (arg === "--port") portRaw = value;
876
+ else baseUrl = value;
877
+ i++;
878
+ continue;
879
+ }
880
+ if (arg.startsWith("--port=")) {
881
+ const value = arg.slice("--port=".length);
882
+ if (value === "") return { error: "takuhon: `--port` requires a value." };
883
+ portRaw = value;
884
+ continue;
885
+ }
886
+ if (arg.startsWith("--base-url=")) {
887
+ const value = arg.slice("--base-url=".length);
888
+ if (value === "") return { error: "takuhon: `--base-url` requires a value." };
889
+ baseUrl = value;
890
+ continue;
891
+ }
892
+ if (arg.startsWith("-")) {
893
+ return { error: `takuhon: unknown option \`${arg}\` for \`dev\`.` };
894
+ }
895
+ if (path !== void 0) {
896
+ return { error: "takuhon: `dev` takes at most one path argument." };
897
+ }
898
+ path = arg;
899
+ }
900
+ let port = DEFAULT_PORT;
901
+ if (portRaw !== void 0) {
902
+ const parsedPort = parsePort(portRaw);
903
+ if (parsedPort === void 0) {
904
+ return {
905
+ error: `takuhon: \`--port\` must be an integer between 1 and 65535 (got \`${portRaw}\`).`
906
+ };
907
+ }
908
+ port = parsedPort;
909
+ }
910
+ if (baseUrl !== void 0 && !isHttpUrl(baseUrl)) {
911
+ return { error: "takuhon: `--base-url` must be an absolute http(s) URL." };
912
+ }
913
+ return {
914
+ path: path ?? DEFAULT_PATH2,
915
+ port,
916
+ // Drop any trailing slash so URL joins are predictable.
917
+ baseUrl: baseUrl?.replace(/\/+$/, "")
918
+ };
919
+ }
920
+ function parsePort(value) {
921
+ if (!/^\d+$/.test(value)) return void 0;
922
+ const n = Number(value);
923
+ return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : void 0;
924
+ }
925
+ function isHttpUrl(value) {
926
+ try {
927
+ const url = new URL(value);
928
+ return url.protocol === "http:" || url.protocol === "https:";
929
+ } catch {
930
+ return false;
931
+ }
932
+ }
933
+ function pathnameOf(url) {
934
+ try {
935
+ return new URL(url, "http://localhost").pathname;
936
+ } catch {
937
+ return url;
938
+ }
939
+ }
940
+ function devPage(title, body) {
941
+ return `<!DOCTYPE html>
942
+ <html lang="en">
943
+ <head>
944
+ <meta charset="utf-8">
945
+ <meta name="viewport" content="width=device-width, initial-scale=1">
946
+ <title>${escapeHtml(title)}</title>
947
+ <style>body{margin:0;font:16px/1.6 system-ui,-apple-system,sans-serif;color:#1a1a1a}main{max-width:42rem;margin:2rem auto;padding:0 1.25rem}pre{background:#f6f6f6;padding:1rem;border-radius:.4rem;overflow:auto;white-space:pre-wrap}code{background:#f2f2f2;padding:.1rem .3rem;border-radius:.2rem}</style>
948
+ </head>
949
+ <body>
950
+ <main>
951
+ ${body}
952
+ </main>
953
+ </body>
954
+ </html>
955
+ `;
956
+ }
957
+ function renderErrorPage(message) {
958
+ return devPage(
959
+ "takuhon dev \u2014 error",
960
+ `<h1>takuhon dev</h1>
961
+ <p>The profile could not be rendered:</p>
962
+ <pre>${escapeHtml(message)}</pre>
963
+ <p>Fix the file and reload.</p>`
964
+ );
965
+ }
966
+ function renderNotFoundPage(route, routes) {
967
+ const links = routes.map((r) => `<li><a href="${escapeHtml(r)}">${escapeHtml(r)}</a></li>`).join("");
968
+ return devPage(
969
+ "takuhon dev \u2014 404",
970
+ `<h1>404</h1>
971
+ <p>No page for <code>${escapeHtml(route)}</code>.</p>
972
+ <p>Available pages:</p>
973
+ <ul>${links}</ul>`
974
+ );
975
+ }
976
+
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");
1016
+ }
1017
+ function isNotFound2(err) {
1018
+ return typeof err === "object" && err !== null && err.code === "ENOENT";
1019
+ }
1020
+ function writeFileAtomicBinary(target, bytes) {
1021
+ const tmp = join4(dirname4(target), `.${basename2(target)}.${String(process.pid)}.tmp`);
1022
+ try {
1023
+ writeFileSync2(tmp, bytes);
1024
+ renameSync2(tmp, target);
1025
+ } catch (error) {
1026
+ rmSync2(tmp, { force: true });
1027
+ throw error;
1028
+ }
1029
+ }
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
+ };
1060
+ }
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
+ };
1132
+
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";
1137
+ function sha256Hex(content) {
1138
+ return createHash("sha256").update(content, "utf8").digest("hex");
1139
+ }
1140
+ function isNotFound3(err) {
1141
+ return typeof err === "object" && err !== null && err.code === "ENOENT";
1142
+ }
1143
+ function readMaybe(path) {
1144
+ try {
1145
+ return readFileSync5(path, "utf8");
1146
+ } catch (err) {
1147
+ if (isNotFound3(err)) return void 0;
1148
+ throw err;
1149
+ }
1150
+ }
1151
+ var FileStorage = class {
1152
+ constructor(path, deps = {}) {
1153
+ this.path = path;
1154
+ this.now = deps.now ?? (() => /* @__PURE__ */ new Date());
1155
+ }
1156
+ path;
1157
+ now;
1158
+ // The filesystem work is synchronous, but the TakuhonStorage contract is
1159
+ // async. Each method runs its body inside `Promise.resolve().then(...)` so a
1160
+ // synchronous throw becomes a rejected promise (e.g. NotFoundError →
1161
+ // ConflictError mapping in the admin API) rather than an exception the caller
1162
+ // must catch outside `await`.
1163
+ getProfile() {
1164
+ return Promise.resolve().then(() => {
1165
+ const raw = readMaybe(this.path);
1166
+ if (raw === void 0) {
1167
+ throw new NotFoundError2("No profile is stored yet.");
1168
+ }
1169
+ let data;
1170
+ try {
1171
+ data = JSON.parse(raw);
1172
+ } catch (err) {
1173
+ throw new Error("The profile file is not valid JSON.", { cause: err });
1174
+ }
1175
+ return { data, version: sha256Hex(raw) };
1176
+ });
1177
+ }
1178
+ saveProfile(data, ifMatch) {
1179
+ return Promise.resolve().then(() => {
1180
+ const current = readMaybe(this.path);
1181
+ if (ifMatch !== void 0) {
1182
+ const currentVersion = current === void 0 ? void 0 : sha256Hex(current);
1183
+ if (currentVersion !== ifMatch) {
1184
+ throw new ConflictError(
1185
+ `If-Match preconditioned on version "${ifMatch}" but current is "${currentVersion ?? "absent"}".`,
1186
+ { currentVersion }
1187
+ );
1188
+ }
1189
+ }
1190
+ if (current !== void 0) {
1191
+ const stamp = this.now();
1192
+ createBackup({
1193
+ targetPath: this.path,
1194
+ content: current,
1195
+ name: (withMillis) => preAdminSaveName(stamp, withMillis)
1196
+ });
1197
+ }
1198
+ const content = `${JSON.stringify(data, null, 2)}
1199
+ `;
1200
+ writeFileAtomic(this.path, content);
1201
+ return { version: sha256Hex(content) };
1202
+ });
1203
+ }
1204
+ deleteProfile() {
1205
+ return Promise.resolve().then(() => {
1206
+ const current = readMaybe(this.path);
1207
+ if (current === void 0) return;
1208
+ const stamp = this.now();
1209
+ createBackup({
1210
+ targetPath: this.path,
1211
+ content: current,
1212
+ name: (withMillis) => preAdminSaveName(stamp, withMillis)
1213
+ });
1214
+ rmSync3(this.path, { force: true });
1215
+ });
1216
+ }
1217
+ };
1218
+
1219
+ // src/admin-command.ts
1220
+ var DEFAULT_PATH3 = "takuhon.json";
1221
+ var DEFAULT_PORT2 = 4322;
1222
+ var USAGE2 = `Usage: takuhon admin [path] [--port <n>] [--base-url <url>]
1223
+
1224
+ Run a local admin server: the form editor at /admin, the admin API at
1225
+ /api/admin/*, and a preview at /. With no path, edits ./takuhon.json. A fresh
1226
+ admin token is printed each run; paste it into the sign-in form. The server
1227
+ binds 127.0.0.1 only. Stop with Ctrl-C.
1228
+
1229
+ Options:
1230
+ --port <n> Port to listen on (default: ${DEFAULT_PORT2}).
1231
+ --base-url <url> Site origin for the preview's canonical / hreflang links.
1232
+
1233
+ Exit codes: 0 = served then stopped, 2 = bad arguments / port in use.
1234
+ `;
1235
+ var CONTENT_TYPES = {
1236
+ ".html": "text/html; charset=utf-8",
1237
+ ".js": "text/javascript; charset=utf-8",
1238
+ ".css": "text/css; charset=utf-8",
1239
+ ".json": "application/json; charset=utf-8",
1240
+ ".svg": "image/svg+xml",
1241
+ ".png": "image/png",
1242
+ ".ico": "image/x-icon",
1243
+ ".woff2": "font/woff2",
1244
+ ".map": "application/json; charset=utf-8"
1245
+ };
1246
+ function contentTypeFor(file) {
1247
+ return CONTENT_TYPES[extname2(file).toLowerCase()] ?? "application/octet-stream";
1248
+ }
1249
+ function isNotFound4(err) {
1250
+ return typeof err === "object" && err !== null && err.code === "ENOENT";
1251
+ }
1252
+ function localAdminHeaders() {
1253
+ const headers = { ...adminAssetSecurityHeaders() };
1254
+ delete headers["strict-transport-security"];
1255
+ const csp = headers["content-security-policy"];
1256
+ if (csp !== void 0) {
1257
+ headers["content-security-policy"] = csp.split(";").map((directive) => directive.trim()).filter((directive) => directive !== "" && directive !== "upgrade-insecure-requests").join("; ");
1258
+ }
1259
+ return headers;
1260
+ }
1261
+ var plain = (status, body) => new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
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) {
1271
+ if (method !== "GET" && method !== "HEAD") return plain(405, "Method Not Allowed\n");
1272
+ const rest = pathname.slice("/admin".length);
1273
+ let rel;
1274
+ try {
1275
+ rel = decodeURIComponent(rest).replace(/^\/+/, "");
1276
+ } catch {
1277
+ rel = "";
1278
+ }
1279
+ if (rel === "") rel = "index.html";
1280
+ const root = resolve3(bundleDir);
1281
+ let full = resolve3(root, rel);
1282
+ if (full !== root && !full.startsWith(root + sep2)) return plain(403, "Forbidden\n");
1283
+ let body;
1284
+ try {
1285
+ body = readFileSync6(full);
1286
+ } catch (err) {
1287
+ if (!isNotFound4(err)) throw err;
1288
+ if (extname2(rel) !== "") return plain(404, "Not Found\n");
1289
+ full = join5(root, "index.html");
1290
+ try {
1291
+ body = readFileSync6(full);
1292
+ } catch {
1293
+ return plain(404, "Not Found\n");
1294
+ }
1295
+ }
1296
+ const contentType2 = contentTypeFor(full);
1297
+ const headers = new Headers(localAdminHeaders());
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
+ }
1303
+ return new Response(method === "HEAD" ? null : body, { status: 200, headers });
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
+ }
1321
+ function createAdminApp(opts) {
1322
+ const bundleDir = opts.bundleDir ?? resolveAdminBundleDir();
1323
+ const storage = new FileStorage(opts.path);
1324
+ const assetStorage = new FileTakuhonAssetStorage(opts.path, { publicBaseUrl: opts.assetBaseUrl });
1325
+ const app = new Hono();
1326
+ app.route(
1327
+ "/api/admin",
1328
+ createAdminApiApp({
1329
+ storage,
1330
+ assetStorage,
1331
+ getAdminToken: () => opts.token,
1332
+ // Loopback, same-origin SPA: no Origin allowlist needed (empty = skip).
1333
+ getAdminOrigins: () => [],
1334
+ cachePurger: noopCachePurger,
1335
+ auditLogger: noopAuditLogger
1336
+ })
1337
+ );
1338
+ app.all("*", (c) => {
1339
+ const { method, path } = c.req;
1340
+ if (path === "/admin" || path.startsWith("/admin/")) {
1341
+ return serveAdminBundle(bundleDir, method, path, opts.token);
1342
+ }
1343
+ if (path.startsWith("/assets/")) {
1344
+ return serveLocalAsset(assetStorage, method, path);
1345
+ }
1346
+ const state = loadSiteState(opts.path, opts.baseUrl);
1347
+ const res = handleRequest(method, path, state);
1348
+ return new Response(method === "HEAD" ? null : res.body, {
1349
+ status: res.status,
1350
+ headers: { "content-type": res.contentType }
1351
+ });
1352
+ });
1353
+ return app;
1354
+ }
1355
+ function parseArgs2(args) {
1356
+ let path;
1357
+ let portRaw;
1358
+ let baseUrl;
1359
+ for (let i = 0; i < args.length; i++) {
1360
+ const arg = args[i];
1361
+ if (arg === "--port" || arg === "--base-url") {
1362
+ const value = args[i + 1];
1363
+ if (value === void 0 || value === "" || value.startsWith("-")) {
1364
+ return { error: `takuhon: \`${arg}\` requires a value.` };
1365
+ }
1366
+ if (arg === "--port") portRaw = value;
1367
+ else baseUrl = value;
1368
+ i++;
1369
+ continue;
1370
+ }
1371
+ if (arg.startsWith("--port=")) {
1372
+ portRaw = arg.slice("--port=".length);
1373
+ if (portRaw === "") return { error: "takuhon: `--port` requires a value." };
1374
+ continue;
1375
+ }
1376
+ if (arg.startsWith("--base-url=")) {
1377
+ baseUrl = arg.slice("--base-url=".length);
1378
+ if (baseUrl === "") return { error: "takuhon: `--base-url` requires a value." };
1379
+ continue;
1380
+ }
1381
+ if (arg.startsWith("-")) return { error: `takuhon: unknown option \`${arg}\` for \`admin\`.` };
1382
+ if (path !== void 0) return { error: "takuhon: `admin` takes at most one path argument." };
1383
+ path = arg;
1384
+ }
1385
+ let port = DEFAULT_PORT2;
1386
+ if (portRaw !== void 0) {
1387
+ if (!/^\d+$/.test(portRaw)) {
1388
+ return {
1389
+ error: `takuhon: \`--port\` must be an integer between 1 and 65535 (got \`${portRaw}\`).`
1390
+ };
1391
+ }
1392
+ const n = Number(portRaw);
1393
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
1394
+ return {
1395
+ error: `takuhon: \`--port\` must be an integer between 1 and 65535 (got \`${portRaw}\`).`
1396
+ };
1397
+ }
1398
+ port = n;
1399
+ }
1400
+ if (baseUrl !== void 0 && !isHttpUrl2(baseUrl)) {
1401
+ return { error: "takuhon: `--base-url` must be an absolute http(s) URL." };
1402
+ }
1403
+ return { path: path ?? DEFAULT_PATH3, port, baseUrl: baseUrl?.replace(/\/+$/, "") };
1404
+ }
1405
+ function isHttpUrl2(value) {
1406
+ try {
1407
+ const url = new URL(value);
1408
+ return url.protocol === "http:" || url.protocol === "https:";
1409
+ } catch {
1410
+ return false;
1411
+ }
1412
+ }
1413
+ async function runAdmin(args = [], deps = {}) {
1414
+ const out = deps.stdout ?? ((text) => void process.stdout.write(text));
1415
+ const err = deps.stderr ?? ((text) => void process.stderr.write(text));
1416
+ if (args[0] === "--help" || args[0] === "-h") {
1417
+ out(USAGE2);
1418
+ return 0;
1419
+ }
1420
+ const parsed = parseArgs2(args);
1421
+ if ("error" in parsed) {
1422
+ err(`${parsed.error}
1423
+ Run \`takuhon admin --help\` for usage.
1424
+ `);
1425
+ return 2;
1426
+ }
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
+ });
1434
+ return await new Promise((resolvePromise) => {
1435
+ let closing = false;
1436
+ function shutdown() {
1437
+ if (closing) return;
1438
+ closing = true;
1439
+ process.removeListener("SIGINT", shutdown);
1440
+ process.removeListener("SIGTERM", shutdown);
1441
+ server.close(() => resolvePromise(0));
1442
+ }
1443
+ const server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: parsed.port }, () => {
1444
+ const origin = `http://127.0.0.1:${parsed.port}`;
1445
+ out(`takuhon admin: editing ${parsed.path} at ${origin}/ (Ctrl-C to stop)
1446
+ `);
1447
+ out(` Admin UI: ${origin}/admin
1448
+ `);
1449
+ out(` Admin token: ${token}
1450
+ `);
1451
+ out(` Paste the token into the sign-in form; it is valid for this run only.
1452
+ `);
1453
+ process.once("SIGINT", shutdown);
1454
+ process.once("SIGTERM", shutdown);
1455
+ });
1456
+ server.on("error", (error) => {
1457
+ if (error.code === "EADDRINUSE") {
1458
+ err(`takuhon: port ${parsed.port} is already in use; pass --port <n> to choose another.
1459
+ `);
1460
+ } else {
1461
+ err(`takuhon: ${error.message}
1462
+ `);
1463
+ }
1464
+ resolvePromise(2);
1465
+ });
1466
+ });
1467
+ }
1468
+
1469
+ // src/build-command.ts
1470
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync7 } from "fs";
1471
+ import { dirname as dirname5, join as join6 } from "path";
1472
+ import { applyPublicPrivacyFilter as applyPublicPrivacyFilter2, normalize as normalize2, validate as validate2 } from "@takuhon/core";
1473
+ var DEFAULT_PATH4 = "takuhon.json";
1474
+ var DEFAULT_OUTPUT = "dist";
1475
+ var USAGE3 = `Usage: takuhon build [path] [--output <dir>] [--base-url <url>]
1476
+
1477
+ Render a takuhon.json into a static site (one HTML page per locale, with
1478
+ build-time Schema.org JSON-LD). With no path, builds ./takuhon.json.
1479
+
1480
+ Options:
1481
+ --output <dir> Output directory (default: ${DEFAULT_OUTPUT}). The default
1482
+ locale is written to <dir>/index.html and each other locale
1483
+ to <dir>/<locale>/index.html.
1484
+ --base-url <url> Site origin (e.g. https://me.example). Enables absolute
1485
+ canonical and hreflang links; without it those are omitted.
1486
+
1487
+ The public privacy filter is applied (meta.privacy is honoured). Asset URLs are
1488
+ referenced as-is and are not copied. The output directory is written into, not
1489
+ cleaned \u2014 use a dedicated/empty directory so stale pages do not linger.
1490
+
1491
+ Exit codes: 0 = built, 1 = source is not a valid profile,
1492
+ 2 = bad arguments / file missing / unreadable / not JSON / write failed.
1493
+ `;
1494
+ function runBuild(args = []) {
1495
+ if (args[0] === "--help" || args[0] === "-h") {
1496
+ return { code: 0, stdout: USAGE3, stderr: "" };
1497
+ }
1498
+ const parsed = parseArgs3(args);
1499
+ if ("error" in parsed) {
1500
+ return {
1501
+ code: 2,
1502
+ stdout: "",
1503
+ stderr: `${parsed.error}
1504
+ Run \`takuhon build --help\` for usage.
1505
+ `
1506
+ };
1507
+ }
1508
+ return buildSite(parsed);
720
1509
  }
721
- function parseArgs2(args) {
1510
+ function parseArgs3(args) {
722
1511
  let path;
723
- let portRaw;
1512
+ let output;
724
1513
  let baseUrl;
725
1514
  for (let i = 0; i < args.length; i++) {
726
1515
  const arg = args[i];
727
- if (arg === "--port" || arg === "--base-url") {
1516
+ if (arg === "--output" || arg === "--base-url") {
728
1517
  const value = args[i + 1];
729
1518
  if (value === void 0 || value === "" || value.startsWith("-")) {
730
1519
  return { error: `takuhon: \`${arg}\` requires a value.` };
731
1520
  }
732
- if (arg === "--port") portRaw = value;
1521
+ if (arg === "--output") output = value;
733
1522
  else baseUrl = value;
734
1523
  i++;
735
1524
  continue;
736
1525
  }
737
- if (arg.startsWith("--port=")) {
738
- const value = arg.slice("--port=".length);
739
- if (value === "") return { error: "takuhon: `--port` requires a value." };
740
- portRaw = value;
1526
+ if (arg.startsWith("--output=")) {
1527
+ const value = arg.slice("--output=".length);
1528
+ if (value === "") return { error: "takuhon: `--output` requires a value." };
1529
+ output = value;
741
1530
  continue;
742
1531
  }
743
1532
  if (arg.startsWith("--base-url=")) {
@@ -747,39 +1536,24 @@ function parseArgs2(args) {
747
1536
  continue;
748
1537
  }
749
1538
  if (arg.startsWith("-")) {
750
- return { error: `takuhon: unknown option \`${arg}\` for \`dev\`.` };
1539
+ return { error: `takuhon: unknown option \`${arg}\` for \`build\`.` };
751
1540
  }
752
1541
  if (path !== void 0) {
753
- return { error: "takuhon: `dev` takes at most one path argument." };
1542
+ return { error: "takuhon: `build` takes at most one path argument." };
754
1543
  }
755
1544
  path = arg;
756
1545
  }
757
- let port = DEFAULT_PORT;
758
- if (portRaw !== void 0) {
759
- const parsedPort = parsePort(portRaw);
760
- if (parsedPort === void 0) {
761
- return {
762
- error: `takuhon: \`--port\` must be an integer between 1 and 65535 (got \`${portRaw}\`).`
763
- };
764
- }
765
- port = parsedPort;
766
- }
767
- if (baseUrl !== void 0 && !isHttpUrl2(baseUrl)) {
1546
+ if (baseUrl !== void 0 && !isHttpUrl3(baseUrl)) {
768
1547
  return { error: "takuhon: `--base-url` must be an absolute http(s) URL." };
769
1548
  }
770
1549
  return {
771
- path: path ?? DEFAULT_PATH2,
772
- port,
1550
+ path: path ?? DEFAULT_PATH4,
1551
+ output: output ?? DEFAULT_OUTPUT,
773
1552
  // Drop any trailing slash so URL joins are predictable.
774
1553
  baseUrl: baseUrl?.replace(/\/+$/, "")
775
1554
  };
776
1555
  }
777
- function parsePort(value) {
778
- if (!/^\d+$/.test(value)) return void 0;
779
- const n = Number(value);
780
- return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : void 0;
781
- }
782
- function isHttpUrl2(value) {
1556
+ function isHttpUrl3(value) {
783
1557
  try {
784
1558
  const url = new URL(value);
785
1559
  return url.protocol === "http:" || url.protocol === "https:";
@@ -787,56 +1561,69 @@ function isHttpUrl2(value) {
787
1561
  return false;
788
1562
  }
789
1563
  }
790
- function pathnameOf(url) {
1564
+ function buildSite(parsed) {
1565
+ const { path, output, baseUrl } = parsed;
1566
+ let raw;
791
1567
  try {
792
- return new URL(url, "http://localhost").pathname;
1568
+ raw = readFileSync7(path, "utf8");
793
1569
  } catch {
794
- return url;
1570
+ return {
1571
+ code: 2,
1572
+ stdout: "",
1573
+ stderr: `takuhon: cannot read '${path}'. Pass a path, or run from a directory containing a takuhon.json.
1574
+ `
1575
+ };
795
1576
  }
796
- }
797
- function devPage(title, body) {
798
- return `<!DOCTYPE html>
799
- <html lang="en">
800
- <head>
801
- <meta charset="utf-8">
802
- <meta name="viewport" content="width=device-width, initial-scale=1">
803
- <title>${escapeHtml(title)}</title>
804
- <style>body{margin:0;font:16px/1.6 system-ui,-apple-system,sans-serif;color:#1a1a1a}main{max-width:42rem;margin:2rem auto;padding:0 1.25rem}pre{background:#f6f6f6;padding:1rem;border-radius:.4rem;overflow:auto;white-space:pre-wrap}code{background:#f2f2f2;padding:.1rem .3rem;border-radius:.2rem}</style>
805
- </head>
806
- <body>
807
- <main>
808
- ${body}
809
- </main>
810
- </body>
811
- </html>
812
- `;
813
- }
814
- function renderErrorPage(message) {
815
- return devPage(
816
- "takuhon dev \u2014 error",
817
- `<h1>takuhon dev</h1>
818
- <p>The profile could not be rendered:</p>
819
- <pre>${escapeHtml(message)}</pre>
820
- <p>Fix the file and reload.</p>`
821
- );
822
- }
823
- function renderNotFoundPage(route, routes) {
824
- const links = routes.map((r) => `<li><a href="${escapeHtml(r)}">${escapeHtml(r)}</a></li>`).join("");
825
- return devPage(
826
- "takuhon dev \u2014 404",
827
- `<h1>404</h1>
828
- <p>No page for <code>${escapeHtml(route)}</code>.</p>
829
- <p>Available pages:</p>
830
- <ul>${links}</ul>`
831
- );
1577
+ let data;
1578
+ try {
1579
+ data = JSON.parse(raw);
1580
+ } catch (error) {
1581
+ const detail = error instanceof Error ? error.message : String(error);
1582
+ return { code: 2, stdout: "", stderr: `takuhon: '${path}' is not valid JSON: ${detail}
1583
+ ` };
1584
+ }
1585
+ const result = validate2(data);
1586
+ if (!result.ok) {
1587
+ const lines = result.errors.map((e) => ` ${e.pointer || "/"}: ${e.message}`);
1588
+ return {
1589
+ code: 1,
1590
+ stdout: "",
1591
+ stderr: `takuhon: '${path}' is not a valid takuhon profile; refusing to build:
1592
+ ${lines.join("\n")}
1593
+ `
1594
+ };
1595
+ }
1596
+ const filtered = applyPublicPrivacyFilter2(normalize2(result.data));
1597
+ const activitySnapshot = filtered.settings.activity?.enabled === true ? readActivitySnapshotSync(path) : null;
1598
+ const written = [];
1599
+ try {
1600
+ for (const page of generateSite(filtered, { baseUrl, activitySnapshot })) {
1601
+ const outFile = join6(output, page.file);
1602
+ mkdirSync3(dirname5(outFile), { recursive: true });
1603
+ writeFileAtomic(outFile, page.html);
1604
+ written.push(outFile);
1605
+ }
1606
+ } catch (error) {
1607
+ const detail = error instanceof Error ? error.message : String(error);
1608
+ return { code: 2, stdout: "", stderr: `takuhon: failed to write the site: ${detail}
1609
+ ` };
1610
+ }
1611
+ const summary = written.map((w) => ` ${w}`).join("\n");
1612
+ return {
1613
+ code: 0,
1614
+ stdout: `built ${written.length} page${written.length === 1 ? "" : "s"} from ${path}:
1615
+ ${summary}
1616
+ `,
1617
+ stderr: ""
1618
+ };
832
1619
  }
833
1620
 
834
1621
  // src/export-command.ts
835
- import { readFileSync as readFileSync3 } from "fs";
836
- import { resolve } from "path";
1622
+ import { readFileSync as readFileSync8 } from "fs";
1623
+ import { resolve as resolve4 } from "path";
837
1624
  import { exportTakuhon, validate as validate3 } from "@takuhon/core";
838
- var DEFAULT_PATH3 = "takuhon.json";
839
- var USAGE3 = `Usage: takuhon export [path] [--output <file>]
1625
+ var DEFAULT_PATH5 = "takuhon.json";
1626
+ var USAGE4 = `Usage: takuhon export [path] [--output <file>]
840
1627
 
841
1628
  Serialise a takuhon.json into its transport form and print it to stdout, or
842
1629
  write it to a file with --output. With no path, exports ./takuhon.json in the
@@ -850,9 +1637,9 @@ Exit codes: 0 = exported, 1 = source is not a valid profile,
850
1637
  `;
851
1638
  function runExport(args = []) {
852
1639
  if (args[0] === "--help" || args[0] === "-h") {
853
- return { code: 0, stdout: USAGE3, stderr: "" };
1640
+ return { code: 0, stdout: USAGE4, stderr: "" };
854
1641
  }
855
- const parsed = parseArgs3(args);
1642
+ const parsed = parseArgs4(args);
856
1643
  if ("error" in parsed) {
857
1644
  return {
858
1645
  code: 2,
@@ -864,7 +1651,7 @@ Run \`takuhon export --help\` for usage.
864
1651
  }
865
1652
  return exportFile(parsed);
866
1653
  }
867
- function parseArgs3(args) {
1654
+ function parseArgs4(args) {
868
1655
  let path;
869
1656
  let output;
870
1657
  for (let i = 0; i < args.length; i++) {
@@ -899,11 +1686,11 @@ function parseArgs3(args) {
899
1686
  }
900
1687
  path = arg;
901
1688
  }
902
- return { path: path ?? DEFAULT_PATH3, output };
1689
+ return { path: path ?? DEFAULT_PATH5, output };
903
1690
  }
904
1691
  function exportFile(parsed) {
905
1692
  const { path, output } = parsed;
906
- if (output !== void 0 && resolve(output) === resolve(path)) {
1693
+ if (output !== void 0 && resolve4(output) === resolve4(path)) {
907
1694
  return {
908
1695
  code: 2,
909
1696
  stdout: "",
@@ -914,7 +1701,7 @@ Omit --output to print to stdout, or choose a different file.
914
1701
  }
915
1702
  let raw;
916
1703
  try {
917
- raw = readFileSync3(path, "utf8");
1704
+ raw = readFileSync8(path, "utf8");
918
1705
  } catch {
919
1706
  return {
920
1707
  code: 2,
@@ -959,7 +1746,7 @@ ${lines.join("\n")}
959
1746
  }
960
1747
 
961
1748
  // src/import-command.ts
962
- import { readFileSync as readFileSync4 } from "fs";
1749
+ import { readFileSync as readFileSync9 } from "fs";
963
1750
  import {
964
1751
  ImportError,
965
1752
  MigrationError,
@@ -967,8 +1754,8 @@ import {
967
1754
  importTakuhon,
968
1755
  migrateTakuhon
969
1756
  } from "@takuhon/core";
970
- var DEFAULT_PATH4 = "takuhon.json";
971
- var USAGE4 = `Usage: takuhon import <file> [path]
1757
+ var DEFAULT_PATH6 = "takuhon.json";
1758
+ var USAGE5 = `Usage: takuhon import <file> [path]
972
1759
 
973
1760
  Load a previously exported profile from <file> into a local takuhon.json,
974
1761
  migrating it to the current schema version if needed. With no path, writes
@@ -982,9 +1769,9 @@ version), 2 = bad arguments / file missing / unreadable / not JSON / write faile
982
1769
  `;
983
1770
  function runImport(args = [], deps = {}) {
984
1771
  if (args[0] === "--help" || args[0] === "-h") {
985
- return { code: 0, stdout: USAGE4, stderr: "" };
1772
+ return { code: 0, stdout: USAGE5, stderr: "" };
986
1773
  }
987
- const parsed = parseArgs4(args);
1774
+ const parsed = parseArgs5(args);
988
1775
  if ("error" in parsed) {
989
1776
  return {
990
1777
  code: 2,
@@ -997,7 +1784,7 @@ Run \`takuhon import --help\` for usage.
997
1784
  const now = deps.now ?? (() => /* @__PURE__ */ new Date());
998
1785
  return importFile(parsed, now);
999
1786
  }
1000
- function parseArgs4(args) {
1787
+ function parseArgs5(args) {
1001
1788
  const positionals = [];
1002
1789
  for (const arg of args) {
1003
1790
  if (arg.startsWith("-")) {
@@ -1011,13 +1798,13 @@ function parseArgs4(args) {
1011
1798
  if (positionals.length > 2) {
1012
1799
  return { error: "takuhon: `import` takes at most an input <file> and a target path." };
1013
1800
  }
1014
- return { file: positionals[0], path: positionals[1] ?? DEFAULT_PATH4 };
1801
+ return { file: positionals[0], path: positionals[1] ?? DEFAULT_PATH6 };
1015
1802
  }
1016
1803
  function importFile(parsed, now) {
1017
1804
  const { file, path } = parsed;
1018
1805
  let raw;
1019
1806
  try {
1020
- raw = readFileSync4(file, "utf8");
1807
+ raw = readFileSync9(file, "utf8");
1021
1808
  } catch {
1022
1809
  return { code: 2, stdout: "", stderr: `takuhon: cannot read '${file}'.
1023
1810
  ` };
@@ -1082,9 +1869,9 @@ ${lines2.join("\n")}` : ".";
1082
1869
  }
1083
1870
  let currentRaw;
1084
1871
  try {
1085
- currentRaw = readFileSync4(path, "utf8");
1872
+ currentRaw = readFileSync9(path, "utf8");
1086
1873
  } catch (error) {
1087
- if (!isNotFound(error)) {
1874
+ if (!isNotFound5(error)) {
1088
1875
  return { code: 2, stdout: "", stderr: `takuhon: cannot read current profile '${path}'.
1089
1876
  ` };
1090
1877
  }
@@ -1123,13 +1910,13 @@ ${lines2.join("\n")}` : ".";
1123
1910
  return { code: 0, stdout: `${lines.join("\n")}
1124
1911
  `, stderr: "" };
1125
1912
  }
1126
- function isNotFound(error) {
1913
+ function isNotFound5(error) {
1127
1914
  return typeof error === "object" && error !== null && error.code === "ENOENT";
1128
1915
  }
1129
1916
 
1130
1917
  // src/migrate-command.ts
1131
- import { readFileSync as readFileSync5 } from "fs";
1132
- import { resolve as resolve2 } from "path";
1918
+ import { readFileSync as readFileSync10 } from "fs";
1919
+ import { resolve as resolve5 } from "path";
1133
1920
  import {
1134
1921
  MigrationError as MigrationError2,
1135
1922
  SCHEMA_VERSION as SCHEMA_VERSION2,
@@ -1137,8 +1924,8 @@ import {
1137
1924
  migrateTakuhon as migrateTakuhon2,
1138
1925
  validate as validate4
1139
1926
  } from "@takuhon/core";
1140
- var DEFAULT_PATH5 = "takuhon.json";
1141
- var USAGE5 = `Usage: takuhon migrate [path] [--to <version>] [--out <file>] [--dry-run]
1927
+ var DEFAULT_PATH7 = "takuhon.json";
1928
+ var USAGE6 = `Usage: takuhon migrate [path] [--to <version>] [--out <file>] [--dry-run]
1142
1929
 
1143
1930
  Forward-migrate a takuhon.json to a newer schema version. The source version
1144
1931
  is read from the file's own schemaVersion. With no path, migrates
@@ -1160,9 +1947,9 @@ Exit codes: 0 = migrated / already current / dry-run, 1 = cannot migrate,
1160
1947
  `;
1161
1948
  function runMigrate(args = [], deps = {}) {
1162
1949
  if (args[0] === "--help" || args[0] === "-h") {
1163
- return { code: 0, stdout: USAGE5, stderr: "" };
1950
+ return { code: 0, stdout: USAGE6, stderr: "" };
1164
1951
  }
1165
- const parsed = parseArgs5(args);
1952
+ const parsed = parseArgs6(args);
1166
1953
  if ("error" in parsed) {
1167
1954
  return {
1168
1955
  code: 2,
@@ -1175,7 +1962,7 @@ Run \`takuhon migrate --help\` for usage.
1175
1962
  const now = deps.now ?? (() => /* @__PURE__ */ new Date());
1176
1963
  return migrateFile(parsed, now);
1177
1964
  }
1178
- function parseArgs5(args) {
1965
+ function parseArgs6(args) {
1179
1966
  let path;
1180
1967
  let to;
1181
1968
  let out;
@@ -1218,13 +2005,13 @@ function parseArgs5(args) {
1218
2005
  error: `takuhon: unsupported --to version "${target}". Supported: ${SUPPORTED_SCHEMA_VERSIONS.join(", ")}.`
1219
2006
  };
1220
2007
  }
1221
- return { path: path ?? DEFAULT_PATH5, to: target, out, dryRun };
2008
+ return { path: path ?? DEFAULT_PATH7, to: target, out, dryRun };
1222
2009
  }
1223
2010
  function migrateFile(parsed, now) {
1224
2011
  const { path, to: target, out, dryRun } = parsed;
1225
2012
  let raw;
1226
2013
  try {
1227
- raw = readFileSync5(path, "utf8");
2014
+ raw = readFileSync10(path, "utf8");
1228
2015
  } catch {
1229
2016
  return {
1230
2017
  code: 2,
@@ -1259,7 +2046,7 @@ function migrateFile(parsed, now) {
1259
2046
  };
1260
2047
  }
1261
2048
  const writeTarget = out ?? path;
1262
- const inPlace = resolve2(writeTarget) === resolve2(path);
2049
+ const inPlace = resolve5(writeTarget) === resolve5(path);
1263
2050
  let migrated;
1264
2051
  try {
1265
2052
  migrated = migrateTakuhon2(data, target);
@@ -1337,11 +2124,119 @@ ${lines2.join("\n")}
1337
2124
  `, stderr: "" };
1338
2125
  }
1339
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
+
1340
2235
  // src/restore-command.ts
1341
- import { readFileSync as readFileSync6 } from "fs";
2236
+ import { readFileSync as readFileSync12 } from "fs";
1342
2237
  import { validate as validate5 } from "@takuhon/core";
1343
- var DEFAULT_PATH6 = "takuhon.json";
1344
- var USAGE6 = `Usage: takuhon restore --from <backup> [path] [--yes]
2238
+ var DEFAULT_PATH8 = "takuhon.json";
2239
+ var USAGE8 = `Usage: takuhon restore --from <backup> [path] [--yes]
1345
2240
 
1346
2241
  Overwrite a profile with a previously saved backup. With no path, restores
1347
2242
  ./takuhon.json in the current working directory.
@@ -1358,9 +2253,9 @@ Exit codes: 0 = restored / aborted, 1 = backup failed validation,
1358
2253
  `;
1359
2254
  async function runRestore(args = [], deps = {}) {
1360
2255
  if (args[0] === "--help" || args[0] === "-h") {
1361
- return { code: 0, stdout: USAGE6, stderr: "" };
2256
+ return { code: 0, stdout: USAGE8, stderr: "" };
1362
2257
  }
1363
- const parsed = parseArgs6(args);
2258
+ const parsed = parseArgs7(args);
1364
2259
  if ("error" in parsed) {
1365
2260
  return {
1366
2261
  code: 2,
@@ -1373,7 +2268,7 @@ Run \`takuhon restore --help\` for usage.
1373
2268
  const now = deps.now ?? (() => /* @__PURE__ */ new Date());
1374
2269
  return restoreFile(parsed, now, deps.confirm);
1375
2270
  }
1376
- function parseArgs6(args) {
2271
+ function parseArgs7(args) {
1377
2272
  let from;
1378
2273
  let path;
1379
2274
  let yes = false;
@@ -1407,13 +2302,13 @@ function parseArgs6(args) {
1407
2302
  if (from === void 0 || from.length === 0) {
1408
2303
  return { error: "takuhon: `restore` requires `--from <backup>`." };
1409
2304
  }
1410
- return { from, path: path ?? DEFAULT_PATH6, yes };
2305
+ return { from, path: path ?? DEFAULT_PATH8, yes };
1411
2306
  }
1412
2307
  async function restoreFile(parsed, now, confirm) {
1413
2308
  const { from, path, yes } = parsed;
1414
2309
  let backupRaw;
1415
2310
  try {
1416
- backupRaw = readFileSync6(from, "utf8");
2311
+ backupRaw = readFileSync12(from, "utf8");
1417
2312
  } catch {
1418
2313
  return { code: 2, stdout: "", stderr: `takuhon: cannot read backup '${from}'.
1419
2314
  ` };
@@ -1443,9 +2338,9 @@ ${lines2.join("\n")}
1443
2338
  }
1444
2339
  let currentRaw;
1445
2340
  try {
1446
- currentRaw = readFileSync6(path, "utf8");
2341
+ currentRaw = readFileSync12(path, "utf8");
1447
2342
  } catch (error) {
1448
- if (!isNotFound2(error)) {
2343
+ if (!isNotFound6(error)) {
1449
2344
  return { code: 2, stdout: "", stderr: `takuhon: cannot read current profile '${path}'.
1450
2345
  ` };
1451
2346
  }
@@ -1508,17 +2403,17 @@ function confirmationMessage(path, from, data, currentRaw, preRestorePath) {
1508
2403
  ${preNote}
1509
2404
  Continue? [y/N]`;
1510
2405
  }
1511
- function isNotFound2(error) {
2406
+ function isNotFound6(error) {
1512
2407
  return typeof error === "object" && error !== null && error.code === "ENOENT";
1513
2408
  }
1514
2409
 
1515
2410
  // src/sync-command.ts
1516
- import { readFileSync as readFileSync7 } from "fs";
2411
+ import { readFileSync as readFileSync13 } from "fs";
1517
2412
  import { validate as validate6 } from "@takuhon/core";
1518
- var DEFAULT_PATH7 = "takuhon.json";
2413
+ var DEFAULT_PATH9 = "takuhon.json";
1519
2414
  var ADMIN_PROFILE_PATH = "/api/admin/profile";
1520
2415
  var TOKEN_ENV = "TAKUHON_ADMIN_TOKEN";
1521
- var USAGE7 = `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]
1522
2417
 
1523
2418
  Push a local takuhon.json to a deployed takuhon instance by calling its admin
1524
2419
  write endpoint (PUT <base-url>/api/admin/profile). With no path, syncs
@@ -1544,9 +2439,9 @@ unset / auth failure / network error / other non-success response.
1544
2439
  `;
1545
2440
  async function runSync(args = [], deps = {}) {
1546
2441
  if (args[0] === "--help" || args[0] === "-h") {
1547
- return { code: 0, stdout: USAGE7, stderr: "" };
2442
+ return { code: 0, stdout: USAGE9, stderr: "" };
1548
2443
  }
1549
- const parsed = parseArgs7(args);
2444
+ const parsed = parseArgs8(args);
1550
2445
  if ("error" in parsed) {
1551
2446
  return {
1552
2447
  code: 2,
@@ -1558,7 +2453,7 @@ Run \`takuhon sync --help\` for usage.
1558
2453
  }
1559
2454
  return syncProfile(parsed, deps);
1560
2455
  }
1561
- function parseArgs7(args) {
2456
+ function parseArgs8(args) {
1562
2457
  let path;
1563
2458
  let url;
1564
2459
  let ifMatch;
@@ -1604,7 +2499,7 @@ function parseArgs7(args) {
1604
2499
  }
1605
2500
  const base = parseOrigin(url);
1606
2501
  if ("error" in base) return base;
1607
- return { path: path ?? DEFAULT_PATH7, url: base.origin, ifMatch, dryRun };
2502
+ return { path: path ?? DEFAULT_PATH9, url: base.origin, ifMatch, dryRun };
1608
2503
  }
1609
2504
  function parseOrigin(value) {
1610
2505
  let parsed;
@@ -1637,7 +2532,7 @@ async function syncProfile(parsed, deps) {
1637
2532
  const { path, url, ifMatch, dryRun } = parsed;
1638
2533
  let raw;
1639
2534
  try {
1640
- raw = readFileSync7(path, "utf8");
2535
+ raw = readFileSync13(path, "utf8");
1641
2536
  } catch {
1642
2537
  return { code: 2, stdout: "", stderr: `takuhon: cannot read '${path}'.
1643
2538
  ` };
@@ -1708,7 +2603,7 @@ Set it for this command only, e.g.:
1708
2603
  async function interpretResponse(res, target) {
1709
2604
  const { path, url, endpoint } = target;
1710
2605
  if (res.ok) {
1711
- const version = await readVersion(res);
2606
+ const version = await readVersion2(res);
1712
2607
  if (version === void 0) {
1713
2608
  return {
1714
2609
  code: 2,
@@ -1750,7 +2645,7 @@ ${lines.join("\n")}` : ".";
1750
2645
  return { code: 2, stdout: "", stderr: `takuhon: sync failed (${String(status)})${tail}
1751
2646
  ` };
1752
2647
  }
1753
- async function readVersion(res) {
2648
+ async function readVersion2(res) {
1754
2649
  try {
1755
2650
  const parsed = await res.json();
1756
2651
  const version = parsed.meta?.version;
@@ -1774,10 +2669,10 @@ async function readProblem(res) {
1774
2669
  }
1775
2670
 
1776
2671
  // src/validate-command.ts
1777
- import { readFileSync as readFileSync8 } from "fs";
2672
+ import { readFileSync as readFileSync14 } from "fs";
1778
2673
  import { validate as validate7 } from "@takuhon/core";
1779
- var DEFAULT_PATH8 = "takuhon.json";
1780
- var USAGE8 = `Usage: takuhon validate [path]
2674
+ var DEFAULT_PATH10 = "takuhon.json";
2675
+ var USAGE10 = `Usage: takuhon validate [path]
1781
2676
 
1782
2677
  Validate a takuhon.json against the takuhon schema. With no path, validates
1783
2678
  ./takuhon.json in the current working directory.
@@ -1786,7 +2681,7 @@ Exit codes: 0 = valid, 1 = invalid, 2 = file missing / unreadable / not JSON.
1786
2681
  `;
1787
2682
  function runValidate(args = []) {
1788
2683
  if (args[0] === "--help" || args[0] === "-h") {
1789
- return { code: 0, stdout: USAGE8, stderr: "" };
2684
+ return { code: 0, stdout: USAGE10, stderr: "" };
1790
2685
  }
1791
2686
  if (args.length > 1) {
1792
2687
  return {
@@ -1798,10 +2693,10 @@ function runValidate(args = []) {
1798
2693
  return validateFile(args[0]);
1799
2694
  }
1800
2695
  function validateFile(pathArg) {
1801
- const target = pathArg ?? DEFAULT_PATH8;
2696
+ const target = pathArg ?? DEFAULT_PATH10;
1802
2697
  let raw;
1803
2698
  try {
1804
- raw = readFileSync8(target, "utf8");
2699
+ raw = readFileSync14(target, "utf8");
1805
2700
  } catch {
1806
2701
  return {
1807
2702
  code: 2,
@@ -1843,7 +2738,7 @@ ${lines.join("\n")}
1843
2738
  }
1844
2739
 
1845
2740
  // src/index.ts
1846
- var pkg = JSON.parse(readFileSync9(new URL("../package.json", import.meta.url), "utf8"));
2741
+ var pkg = JSON.parse(readFileSync15(new URL("../package.json", import.meta.url), "utf8"));
1847
2742
  var VERSION = pkg.version;
1848
2743
  var HELP = `takuhon ${VERSION}
1849
2744
 
@@ -1869,10 +2764,22 @@ Commands:
1869
2764
  takuhon dev [path] [--port <n>] Serve a takuhon.json as a local static preview,
1870
2765
  re-rendered on each request (default port: 4321).
1871
2766
  --base-url <url> adds canonical/hreflang links.
2767
+ takuhon admin [path] [--port <n>] Run a local admin server: edit the profile through
2768
+ the form UI at /admin (writes takuhon.json, backs up
2769
+ first) with a preview at / (default port: 4322). Binds
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.
1872
2774
  takuhon sync [path] --url <url> Push a takuhon.json to a deployment's admin API
1873
2775
  (PUT <url>/api/admin/profile). Reads the admin token
1874
2776
  from TAKUHON_ADMIN_TOKEN. --if-match <etag> opts into
1875
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).
1876
2783
 
1877
2784
  Scaffolding a new profile project:
1878
2785
  npx create-takuhon my-profile
@@ -1904,6 +2811,12 @@ async function main(argv) {
1904
2811
  if (first === "dev") {
1905
2812
  return runDev(argv.slice(1));
1906
2813
  }
2814
+ if (first === "admin") {
2815
+ if (argv[1] === "update") {
2816
+ return emit(await runRefreshAdmin(argv.slice(2)));
2817
+ }
2818
+ return runAdmin(argv.slice(1));
2819
+ }
1907
2820
  if (first === "import") {
1908
2821
  return emit(runImport(argv.slice(1)));
1909
2822
  }
@@ -1914,6 +2827,26 @@ async function main(argv) {
1914
2827
  if (first === "sync") {
1915
2828
  return emit(await runSync(argv.slice(1)));
1916
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
+ }
1917
2850
  process.stderr.write(
1918
2851
  `takuhon: unknown command '${first}'
1919
2852
  Run \`takuhon --help\` for usage. For scaffolding a new project, use \`create-takuhon\`.