@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/admin-bundle/assets/index-C3RPKgaS.js +9 -0
- package/admin-bundle/assets/{style-Cyep5p4h.css → style-B1PQ2qaY.css} +1 -1
- package/admin-bundle/index.html +2 -2
- package/dist/{chunk-JI2AH5Y3.js → chunk-XMSWT5NY.js} +77 -6
- package/dist/chunk-XMSWT5NY.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +763 -153
- package/dist/index.js.map +1 -1
- package/dist/init.js +1 -1
- package/package.json +4 -3
- package/admin-bundle/assets/index-C2-9nYVF.js +0 -9
- package/dist/chunk-JI2AH5Y3.js.map +0 -1
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-
|
|
5
|
+
} from "./chunk-XMSWT5NY.js";
|
|
5
6
|
|
|
6
7
|
// src/index.ts
|
|
7
|
-
import { readFileSync as
|
|
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
|
|
15
|
-
import { extname, join as
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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({
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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((
|
|
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(() =>
|
|
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
|
-
|
|
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 ??
|
|
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 {
|
|
634
|
-
import {
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
function
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
|
665
|
-
return
|
|
1017
|
+
function isNotFound2(err) {
|
|
1018
|
+
return typeof err === "object" && err !== null && err.code === "ENOENT";
|
|
666
1019
|
}
|
|
667
|
-
function
|
|
668
|
-
const
|
|
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
|
-
|
|
673
|
-
|
|
1023
|
+
writeFileSync2(tmp, bytes);
|
|
1024
|
+
renameSync2(tmp, target);
|
|
674
1025
|
} catch (error) {
|
|
675
|
-
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
|
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
|
|
1145
|
+
return readFileSync5(path, "utf8");
|
|
715
1146
|
} catch (err) {
|
|
716
|
-
if (
|
|
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
|
|
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
|
-
|
|
1214
|
+
rmSync3(this.path, { force: true });
|
|
784
1215
|
});
|
|
785
1216
|
}
|
|
786
1217
|
};
|
|
787
1218
|
|
|
788
1219
|
// src/admin-command.ts
|
|
789
|
-
var
|
|
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[
|
|
1247
|
+
return CONTENT_TYPES[extname2(file).toLowerCase()] ?? "application/octet-stream";
|
|
817
1248
|
}
|
|
818
|
-
function
|
|
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
|
|
1262
|
+
function escapeHtmlAttr(value) {
|
|
1263
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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 =
|
|
842
|
-
let full =
|
|
843
|
-
if (full !== root && !full.startsWith(root +
|
|
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 =
|
|
1285
|
+
body = readFileSync6(full);
|
|
847
1286
|
} catch (err) {
|
|
848
|
-
if (!
|
|
849
|
-
if (
|
|
850
|
-
full =
|
|
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 =
|
|
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",
|
|
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 ??
|
|
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 ??
|
|
963
|
-
const app = createAdminApp({
|
|
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
|
|
1001
|
-
import { dirname as
|
|
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
|
|
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 ??
|
|
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 =
|
|
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 =
|
|
1131
|
-
|
|
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
|
|
1152
|
-
import { resolve as
|
|
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
|
|
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 ??
|
|
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 &&
|
|
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 =
|
|
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
|
|
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
|
|
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] ??
|
|
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 =
|
|
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 =
|
|
1872
|
+
currentRaw = readFileSync9(path, "utf8");
|
|
1402
1873
|
} catch (error) {
|
|
1403
|
-
if (!
|
|
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
|
|
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
|
|
1448
|
-
import { resolve as
|
|
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
|
|
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 ??
|
|
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 =
|
|
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 =
|
|
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
|
|
2236
|
+
import { readFileSync as readFileSync12 } from "fs";
|
|
1658
2237
|
import { validate as validate5 } from "@takuhon/core";
|
|
1659
|
-
var
|
|
1660
|
-
var
|
|
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:
|
|
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 ??
|
|
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 =
|
|
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 =
|
|
2341
|
+
currentRaw = readFileSync12(path, "utf8");
|
|
1763
2342
|
} catch (error) {
|
|
1764
|
-
if (!
|
|
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
|
|
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
|
|
2411
|
+
import { readFileSync as readFileSync13 } from "fs";
|
|
1833
2412
|
import { validate as validate6 } from "@takuhon/core";
|
|
1834
|
-
var
|
|
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
|
|
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:
|
|
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 ??
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
2672
|
+
import { readFileSync as readFileSync14 } from "fs";
|
|
2094
2673
|
import { validate as validate7 } from "@takuhon/core";
|
|
2095
|
-
var
|
|
2096
|
-
var
|
|
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:
|
|
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 ??
|
|
2696
|
+
const target = pathArg ?? DEFAULT_PATH10;
|
|
2118
2697
|
let raw;
|
|
2119
2698
|
try {
|
|
2120
|
-
raw =
|
|
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(
|
|
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\`.
|