@jant/core 0.3.45 → 0.3.47

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.
Files changed (114) hide show
  1. package/bin/commands/db/execute-file.js +12 -4
  2. package/bin/commands/db/rehearse.js +2 -2
  3. package/bin/commands/export.js +12 -4
  4. package/bin/commands/import-site.js +99 -305
  5. package/bin/commands/migrate.js +36 -69
  6. package/bin/commands/reset-password.js +10 -4
  7. package/bin/commands/site/export.js +59 -248
  8. package/bin/commands/site/snapshot/export.js +58 -45
  9. package/bin/commands/site/snapshot/import.js +104 -52
  10. package/bin/lib/node-env.js +100 -0
  11. package/bin/lib/runtime-target.js +64 -0
  12. package/bin/lib/site-snapshot.js +185 -54
  13. package/bin/lib/sql-export.js +19 -2
  14. package/dist/{app-C-L7wL6o.js → app-3REcR-3U.js} +332 -190
  15. package/dist/app-B67XOEyo.js +6 -0
  16. package/dist/client/.vite/manifest.json +2 -2
  17. package/dist/client/_assets/{client-auth-Dcon89Av.js → client-auth-Ce5WEAVS.js} +236 -183
  18. package/dist/client/_assets/client-s71Js1Cu.css +2 -0
  19. package/dist/{github-sync-CQ1x271f.js → export-ZBlfKSKm.js} +12 -439
  20. package/dist/github-sync-C593r22F.js +4 -0
  21. package/dist/github-sync-bL1hnx3Q.js +428 -0
  22. package/dist/index.js +3 -2
  23. package/dist/node.js +5 -4
  24. package/package.json +3 -2
  25. package/src/__tests__/helpers/export-fixtures.ts +0 -1
  26. package/src/__tests__/import-site-command.test.ts +18 -0
  27. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -0
  28. package/src/client/components/__tests__/jant-settings-general.test.ts +70 -0
  29. package/src/client/components/jant-compose-dialog.ts +7 -6
  30. package/src/client/components/jant-compose-editor.ts +6 -5
  31. package/src/client/components/jant-settings-general.ts +164 -22
  32. package/src/client/components/settings-types.ts +4 -6
  33. package/src/client/random-uuid.ts +23 -0
  34. package/src/client-auth.ts +1 -1
  35. package/src/db/__tests__/demo-canonical-snapshot.test.ts +1 -1
  36. package/src/db/__tests__/migration-rehearsal.test.ts +2 -5
  37. package/src/db/backfills/0004_register_apple_touch_media_rows.sql +65 -0
  38. package/src/db/migrations/0021_thankful_phalanx.sql +16 -0
  39. package/src/db/migrations/meta/0021_snapshot.json +2121 -0
  40. package/src/db/migrations/meta/_journal.json +7 -0
  41. package/src/db/migrations/pg/0019_gray_natasha_romanoff.sql +20 -0
  42. package/src/db/migrations/pg/meta/0019_snapshot.json +2718 -0
  43. package/src/db/migrations/pg/meta/_journal.json +7 -0
  44. package/src/db/pg/schema.ts +21 -26
  45. package/src/db/rehearsal-fixtures/demo-current.json +1 -1
  46. package/src/db/schema.ts +16 -20
  47. package/src/i18n/__tests__/middleware.test.ts +43 -1
  48. package/src/i18n/coverage.generated.ts +17 -0
  49. package/src/i18n/i18n.ts +18 -2
  50. package/src/i18n/index.ts +3 -0
  51. package/src/i18n/locales/settings/en.po +16 -11
  52. package/src/i18n/locales/settings/en.ts +1 -1
  53. package/src/i18n/locales/settings/zh-Hans.po +17 -12
  54. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  55. package/src/i18n/locales/settings/zh-Hant.po +16 -11
  56. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  57. package/src/i18n/locales.ts +84 -2
  58. package/src/i18n/middleware.ts +25 -16
  59. package/src/i18n/supported-locales.ts +153 -0
  60. package/src/lib/__tests__/csp-builder.test.ts +19 -2
  61. package/src/lib/__tests__/feed.test.ts +242 -1
  62. package/src/lib/__tests__/post-meta.test.ts +0 -1
  63. package/src/lib/__tests__/view.test.ts +0 -1
  64. package/src/lib/csp-builder.ts +28 -10
  65. package/src/lib/feed.ts +153 -3
  66. package/src/middleware/__tests__/secure-headers.test.ts +89 -0
  67. package/src/middleware/auth.ts +1 -1
  68. package/src/middleware/secure-headers.ts +47 -1
  69. package/src/node/__tests__/cli-runtime-target.test.ts +110 -2
  70. package/src/node/__tests__/cli-site-snapshot.test.ts +308 -13
  71. package/src/node/__tests__/cli-site-token-env.test.ts +2 -7
  72. package/src/node/__tests__/cli-snapshot-meta.test.ts +85 -0
  73. package/src/node/__tests__/cli-sql-export.test.ts +49 -0
  74. package/src/node/index.ts +1 -0
  75. package/src/preset.css +8 -2
  76. package/src/routes/api/__tests__/settings.test.ts +3 -2
  77. package/src/routes/api/github-sync.tsx +1 -1
  78. package/src/routes/api/settings.ts +4 -1
  79. package/src/routes/auth/signin.tsx +6 -0
  80. package/src/routes/pages/archive.tsx +4 -2
  81. package/src/services/__tests__/post.test.ts +19 -19
  82. package/src/services/__tests__/search.test.ts +0 -1
  83. package/src/services/__tests__/settings.test.ts +22 -3
  84. package/src/services/bootstrap.ts +7 -3
  85. package/src/services/collection.ts +3 -3
  86. package/src/services/export.ts +0 -3
  87. package/src/services/navigation.ts +0 -2
  88. package/src/services/path.ts +1 -38
  89. package/src/services/post.ts +32 -66
  90. package/src/services/search.ts +0 -6
  91. package/src/services/settings.ts +47 -6
  92. package/src/services/site-admin.ts +6 -1
  93. package/src/styles/ui.css +12 -23
  94. package/src/types/entities.ts +0 -1
  95. package/src/ui/color-themes.ts +1 -1
  96. package/src/ui/dash/settings/GeneralContent.tsx +17 -19
  97. package/src/ui/dash/settings/SettingsRootContent.tsx +17 -28
  98. package/src/ui/feed/NoteCard.tsx +1 -11
  99. package/src/ui/feed/__tests__/timeline-cards.test.ts +1 -1
  100. package/src/ui/pages/HomePage.tsx +1 -4
  101. package/src/ui/pages/PostPage.tsx +2 -0
  102. package/bin/commands/collections.js +0 -268
  103. package/bin/commands/media.js +0 -302
  104. package/bin/commands/posts.js +0 -262
  105. package/bin/commands/search.js +0 -53
  106. package/bin/commands/settings.js +0 -93
  107. package/bin/lib/http-api.js +0 -223
  108. package/bin/lib/media-upload.js +0 -206
  109. package/dist/app-Hvqe7Ks_.js +0 -5
  110. package/dist/client/_assets/client-DDs6NzB3.css +0 -2
  111. package/src/__tests__/bin/content-cli.test.ts +0 -179
  112. package/src/__tests__/bin/media-cli.test.ts +0 -192
  113. /package/dist/{github-api-BkRWnqMx.js → github-api-Bh0PH3zr.js} +0 -0
  114. /package/dist/{github-app-WeadXMb8.js → github-app-D0GvNnqp.js} +0 -0
@@ -47,6 +47,20 @@ function shouldBlockFraming(path: string): boolean {
47
47
  );
48
48
  }
49
49
 
50
+ /**
51
+ * Paths whose responses are not HTML rendered by `BaseLayout` and therefore
52
+ * never carry author-pasted `customHeadHtml` / `customBodyEndHtml`. Skipping
53
+ * the settings lookup for these avoids two DB roundtrips on every static
54
+ * asset request.
55
+ */
56
+ function couldRenderCodeInjection(path: string): boolean {
57
+ if (shouldBlockFraming(path)) return false;
58
+ if (path === "/favicon.ico" || path === "/apple-touch-icon.png") return false;
59
+ if (path === "/healthz" || path === "/readyz") return false;
60
+ if (path.startsWith("/media/") || path.startsWith("/sites/")) return false;
61
+ return true;
62
+ }
63
+
50
64
  function tryGetOrigin(value: string | undefined): string | null {
51
65
  if (!value) return null;
52
66
  try {
@@ -109,6 +123,7 @@ function toHonoCspOptions(
109
123
  function buildSecureHeadersOptions(
110
124
  path: string,
111
125
  env: Bindings,
126
+ allowInlineScript: boolean,
112
127
  ): SecureHeadersOptions {
113
128
  const directives = buildCspDirectives({
114
129
  path,
@@ -116,6 +131,7 @@ function buildSecureHeadersOptions(
116
131
  assetOrigin: tryGetOrigin(getEnvString(env, "ASSET_BASE_URL")),
117
132
  uploadConnectSources: getDirectUploadConnectSources(env),
118
133
  isDev: IS_VITE_DEV,
134
+ allowInlineScript,
119
135
  });
120
136
 
121
137
  return {
@@ -134,8 +150,38 @@ function buildSecureHeadersOptions(
134
150
  };
135
151
  }
136
152
 
153
+ /**
154
+ * Probe the settings service for any author-saved code injection. Resolves to
155
+ * `false` whenever the lookup is unavailable (e.g. in unit tests that skip the
156
+ * runtime middleware) or the path can't render `BaseLayout`.
157
+ *
158
+ * Costs two settings reads on public HTML routes; static asset and
159
+ * frame-protected paths are short-circuited above.
160
+ */
161
+ async function detectInlineScriptOptIn(
162
+ path: string,
163
+ settings: { get(key: string): Promise<string | null> } | undefined,
164
+ ): Promise<boolean> {
165
+ if (!settings) return false;
166
+ if (!couldRenderCodeInjection(path)) return false;
167
+ const [head, bodyEnd] = await Promise.all([
168
+ settings.get("CUSTOM_HEAD_HTML"),
169
+ settings.get("CUSTOM_BODY_END_HTML"),
170
+ ]);
171
+ return Boolean(head?.trim() || bodyEnd?.trim());
172
+ }
173
+
137
174
  export function secureHeadersMiddleware(): MiddlewareHandler<Env> {
138
175
  return async (c, next) => {
139
- return secureHeaders(buildSecureHeadersOptions(c.req.path, c.env))(c, next);
176
+ // `services` is set by the runtime bootstrap middleware. Cast through
177
+ // `undefined` so unit tests that skip that middleware still work.
178
+ const services = c.var.services as AppVariables["services"] | undefined;
179
+ const allowInlineScript = await detectInlineScriptOptIn(
180
+ c.req.path,
181
+ services?.settings,
182
+ );
183
+ return secureHeaders(
184
+ buildSecureHeadersOptions(c.req.path, c.env, allowInlineScript),
185
+ )(c, next);
140
186
  };
141
187
  }
@@ -1,5 +1,12 @@
1
- import { describe, expect, it } from "vitest";
2
- import { resolveCliRuntime } from "../../../bin/lib/runtime-target.js";
1
+ import { mkdtempSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { loadNodeEnvFile } from "../../../bin/lib/node-env.js";
6
+ import {
7
+ formatRuntimeBanner,
8
+ resolveCliRuntime,
9
+ } from "../../../bin/lib/runtime-target.js";
3
10
 
4
11
  describe("resolveCliRuntime", () => {
5
12
  it("prefers the Node database runtime when DATABASE_URL is present", () => {
@@ -34,4 +41,105 @@ describe("resolveCliRuntime", () => {
34
41
  /Choose only one of --local, --remote/,
35
42
  );
36
43
  });
44
+
45
+ it("forces the Node runtime when --node is passed even without DATABASE_URL", () => {
46
+ expect(resolveCliRuntime({ node: true }, {})).toBe("node");
47
+ });
48
+ });
49
+
50
+ describe("loadNodeEnvFile", () => {
51
+ let tmpDir: string;
52
+
53
+ beforeEach(() => {
54
+ tmpDir = mkdtempSync(join(tmpdir(), "jant-env-"));
55
+ });
56
+
57
+ it("assigns missing keys but never overwrites existing ones", () => {
58
+ const envPath = join(tmpDir, ".env.node");
59
+ writeFileSync(
60
+ envPath,
61
+ [
62
+ "# comment",
63
+ "",
64
+ "DATABASE_URL=postgres://example/db",
65
+ "AUTH_SECRET=already-set-in-shell",
66
+ ].join("\n"),
67
+ );
68
+
69
+ const env: Record<string, string> = {
70
+ AUTH_SECRET: "shell-wins",
71
+ };
72
+ const result = loadNodeEnvFile(envPath, env);
73
+
74
+ expect(result.found).toBe(true);
75
+ expect(result.assignedKeys).toEqual(["DATABASE_URL"]);
76
+ expect(result.skippedKeys).toEqual(["AUTH_SECRET"]);
77
+ expect(env.DATABASE_URL).toBe("postgres://example/db");
78
+ expect(env.AUTH_SECRET).toBe("shell-wins");
79
+ });
80
+
81
+ it("returns found=false when the file does not exist", () => {
82
+ const result = loadNodeEnvFile(join(tmpDir, "missing"));
83
+ expect(result.found).toBe(false);
84
+ expect(result.assignedKeys).toEqual([]);
85
+ });
86
+
87
+ it("strips matching surrounding quotes from values", () => {
88
+ const envPath = join(tmpDir, ".env.node");
89
+ writeFileSync(
90
+ envPath,
91
+ [
92
+ 'STORAGE_DRIVER="local"',
93
+ "SITE_NAME='Quoted Title'",
94
+ "PUBLIC_URL=https://example.com",
95
+ 'MIXED="left only',
96
+ ].join("\n"),
97
+ );
98
+
99
+ const env: Record<string, string> = {};
100
+ loadNodeEnvFile(envPath, env);
101
+
102
+ expect(env.STORAGE_DRIVER).toBe("local");
103
+ expect(env.SITE_NAME).toBe("Quoted Title");
104
+ expect(env.PUBLIC_URL).toBe("https://example.com");
105
+ expect(env.MIXED).toBe('"left only');
106
+ });
107
+ });
108
+
109
+ describe("formatRuntimeBanner", () => {
110
+ const originalLog = console.log;
111
+ beforeEach(() => {
112
+ console.log = vi.fn();
113
+ });
114
+ afterEach(() => {
115
+ console.log = originalLog;
116
+ });
117
+
118
+ it("describes a postgres node target", () => {
119
+ const banner = formatRuntimeBanner("node", {
120
+ DATABASE_URL: "postgresql://app@db.local:5432/jant_main",
121
+ });
122
+ expect(banner).toBe(
123
+ "[jant] target = node (postgresql db.local:5432/jant_main)",
124
+ );
125
+ });
126
+
127
+ it("describes a sqlite node target", () => {
128
+ const banner = formatRuntimeBanner("node", {
129
+ DATABASE_URL: "file:./local.sqlite",
130
+ });
131
+ expect(banner).toBe("[jant] target = node (sqlite file:./local.sqlite)");
132
+ });
133
+
134
+ it("describes wrangler local D1", () => {
135
+ expect(formatRuntimeBanner("d1-local", {})).toBe(
136
+ "[jant] target = local D1 (wrangler)",
137
+ );
138
+ });
139
+
140
+ it("describes wrangler remote D1", () => {
141
+ expect(formatRuntimeBanner("d1-remote", {})).toBe(
142
+ "[jant] target = remote D1 (wrangler)",
143
+ );
144
+ });
37
145
  });
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
3
  import { tmpdir } from "node:os";
3
4
  import { join } from "node:path";
@@ -18,6 +19,7 @@ const SNAPSHOT_POST_ID = "pst_01jpyy18fh4w2m7r8k3c5t9qdn";
18
19
  const SNAPSHOT_PATH_ID = "pth_01jpyy1k2v6m4s8r1t5c9b3qgh";
19
20
  const SNAPSHOT_MEDIA_ID = "med_01jpyy1vxh4m7s2k8r5c9t3qbn";
20
21
  const SNAPSHOT_AVATAR_MEDIA_ID = "med_01jpyy1zs6m4v8r2k5t9c3b7qh";
22
+ const SNAPSHOT_APPLE_TOUCH_MEDIA_ID = "med_01jpyy20kt5n9r3k8t6c4d2qhf";
21
23
  const SNAPSHOT_MEDIA_KEY = `media/${SNAPSHOT_SITE_ID}/files/${SNAPSHOT_MEDIA_ID}.png`;
22
24
  const SNAPSHOT_POSTER_KEY = `media/${SNAPSHOT_SITE_ID}/posters/${SNAPSHOT_MEDIA_ID}.webp`;
23
25
  const SNAPSHOT_AVATAR_KEY = `media/${SNAPSHOT_SITE_ID}/assets/avatar/${SNAPSHOT_AVATAR_MEDIA_ID}.png`;
@@ -154,6 +156,24 @@ describe("jant site snapshot export/import", () => {
154
156
  'local', 1, 1, 'Sample alt', 'a0', '${SNAPSHOT_POSTER_KEY}', 'image',
155
157
  1774009200, 1774009200
156
158
  );
159
+
160
+ INSERT INTO "media" (
161
+ "id", "site_id", "post_id", "filename", "original_name", "mime_type", "size", "storage_key",
162
+ "provider", "position", "media_kind", "created_at", "updated_at"
163
+ ) VALUES (
164
+ '${SNAPSHOT_AVATAR_MEDIA_ID}', '${SNAPSHOT_SITE_ID}', NULL,
165
+ '${SNAPSHOT_AVATAR_MEDIA_ID}.png', 'avatar.png', 'image/png', 3, '${SNAPSHOT_AVATAR_KEY}',
166
+ 'local', 'a0', 'image', 1774009202, 1774009202
167
+ );
168
+
169
+ INSERT INTO "media" (
170
+ "id", "site_id", "post_id", "filename", "original_name", "mime_type", "size", "storage_key",
171
+ "provider", "position", "media_kind", "created_at", "updated_at"
172
+ ) VALUES (
173
+ '${SNAPSHOT_APPLE_TOUCH_MEDIA_ID}', '${SNAPSHOT_SITE_ID}', NULL,
174
+ 'apple-touch-icon.png', 'apple-touch-icon.png', 'image/png', 3, '${SNAPSHOT_APPLE_TOUCH_KEY}',
175
+ 'local', 'a0', 'image', 1774009203, 1774009203
176
+ );
157
177
  `);
158
178
 
159
179
  targetSqlite.exec(`
@@ -207,15 +227,16 @@ describe("jant site snapshot export/import", () => {
207
227
  await import("../../../bin/commands/site/snapshot/export.js");
208
228
  await runExport(["--output", snapshotPath]);
209
229
 
210
- const manifest = JSON.parse(
211
- await readFile(join(snapshotPath, "storage-manifest.json"), "utf-8"),
230
+ const meta = JSON.parse(
231
+ await readFile(join(snapshotPath, "meta.json"), "utf-8"),
212
232
  );
213
- expect(manifest.objects.map((object) => object.key)).toEqual([
214
- SNAPSHOT_AVATAR_KEY,
215
- SNAPSHOT_APPLE_TOUCH_KEY,
216
- SNAPSHOT_MEDIA_KEY,
217
- SNAPSHOT_POSTER_KEY,
218
- ]);
233
+ expect(meta).toEqual({
234
+ format: "jant-site-snapshot",
235
+ version: 1,
236
+ dialect: "sqlite",
237
+ site: { id: SNAPSHOT_SITE_ID, key: SNAPSHOT_SITE_KEY },
238
+ });
239
+ expect(existsSync(join(snapshotPath, "storage-manifest.json"))).toBe(false);
219
240
  expect(exportLogSpy).toHaveBeenCalledWith(
220
241
  `Exported Node database snapshot to ${snapshotPath}`,
221
242
  );
@@ -311,6 +332,249 @@ describe("jant site snapshot export/import", () => {
311
332
  );
312
333
  });
313
334
 
335
+ it("skips downloading storage objects when --skip-objects is passed", async () => {
336
+ const root = await mkdtemp(
337
+ join(tmpdir(), "jant-site-snapshot-skip-objects-"),
338
+ );
339
+ tempDirs.push(root);
340
+
341
+ const sourceDbPath = join(root, "source.sqlite");
342
+ const sourceStoragePath = join(root, "source-media");
343
+ const snapshotPath = join(root, "snapshot");
344
+
345
+ await migrate({ DATABASE_URL: `file:${sourceDbPath}` } as Bindings);
346
+ const sourceStorage = createLocalDriver({ rootPath: sourceStoragePath });
347
+ await sourceStorage.put(SNAPSHOT_MEDIA_KEY, new Uint8Array([1, 2, 3, 4]), {
348
+ contentType: "image/png",
349
+ });
350
+
351
+ const sourceSqlite = new Database(sourceDbPath);
352
+ try {
353
+ sourceSqlite.exec(`
354
+ INSERT INTO "site" ("id", "key", "status", "created_at", "updated_at")
355
+ VALUES ('${SNAPSHOT_SITE_ID}', '${SNAPSHOT_SITE_KEY}', 'active', 1774009100, 1774009100);
356
+
357
+ INSERT INTO "site_setting" ("site_id", "key", "value", "updated_at") VALUES
358
+ ('${SNAPSHOT_SITE_ID}', 'SITE_NAME', 'Skip Objects Source', 1774009200);
359
+
360
+ INSERT INTO "post" (
361
+ "id", "site_id", "format", "status", "visibility", "title", "body", "body_html", "body_text",
362
+ "thread_id", "published_at", "last_activity_at", "created_at", "updated_at"
363
+ ) VALUES (
364
+ '${SNAPSHOT_POST_ID}', '${SNAPSHOT_SITE_ID}', 'note', 'published', 'public',
365
+ 'Snapshot post', 'Hello snapshot', '<p>Hello snapshot</p>', 'Hello snapshot',
366
+ '${SNAPSHOT_POST_ID}', 1774009200, 1774009200, 1774009200, 1774009200
367
+ );
368
+
369
+ INSERT INTO "media" (
370
+ "id", "site_id", "post_id", "filename", "original_name", "mime_type", "size", "storage_key",
371
+ "provider", "position", "media_kind", "created_at", "updated_at"
372
+ ) VALUES (
373
+ '${SNAPSHOT_MEDIA_ID}', '${SNAPSHOT_SITE_ID}', '${SNAPSHOT_POST_ID}',
374
+ '${SNAPSHOT_MEDIA_ID}.png', 'sample.png', 'image/png', 4, '${SNAPSHOT_MEDIA_KEY}',
375
+ 'local', 'a0', 'image', 1774009200, 1774009200
376
+ );
377
+ `);
378
+ } finally {
379
+ sourceSqlite.close();
380
+ }
381
+
382
+ process.env.DATABASE_URL = `file:${sourceDbPath}`;
383
+ process.env.LOCAL_STORAGE_PATH = sourceStoragePath;
384
+
385
+ vi.spyOn(console, "log").mockImplementation(() => {});
386
+ const { run: runExport } =
387
+ await import("../../../bin/commands/site/snapshot/export.js");
388
+ await runExport(["--output", snapshotPath, "--skip-objects"]);
389
+
390
+ expect(existsSync(join(snapshotPath, "meta.json"))).toBe(true);
391
+ expect(existsSync(join(snapshotPath, "db.sql"))).toBe(true);
392
+ expect(existsSync(join(snapshotPath, "objects"))).toBe(false);
393
+
394
+ const dbSql = await readFile(join(snapshotPath, "db.sql"), "utf-8");
395
+ expect(dbSql).toContain(SNAPSHOT_MEDIA_KEY);
396
+ });
397
+
398
+ it("aborts import when objects/ is missing keys referenced by db.sql", async () => {
399
+ const root = await mkdtemp(
400
+ join(tmpdir(), "jant-site-snapshot-missing-objects-"),
401
+ );
402
+ tempDirs.push(root);
403
+
404
+ const sourceDbPath = join(root, "source.sqlite");
405
+ const sourceStoragePath = join(root, "source-media");
406
+ const targetDbPath = join(root, "target.sqlite");
407
+ const targetStoragePath = join(root, "target-media");
408
+ const snapshotPath = join(root, "snapshot");
409
+
410
+ await migrate({ DATABASE_URL: `file:${sourceDbPath}` } as Bindings);
411
+ await migrate({ DATABASE_URL: `file:${targetDbPath}` } as Bindings);
412
+
413
+ const sourceStorage = createLocalDriver({ rootPath: sourceStoragePath });
414
+ await sourceStorage.put(SNAPSHOT_MEDIA_KEY, new Uint8Array([1, 2, 3, 4]), {
415
+ contentType: "image/png",
416
+ });
417
+
418
+ const sourceSqlite = new Database(sourceDbPath);
419
+ const targetSqlite = new Database(targetDbPath);
420
+ try {
421
+ sourceSqlite.exec(`
422
+ INSERT INTO "site" ("id", "key", "status", "created_at", "updated_at")
423
+ VALUES ('${SNAPSHOT_SITE_ID}', '${SNAPSHOT_SITE_KEY}', 'active', 1774009100, 1774009100);
424
+
425
+ INSERT INTO "site_setting" ("site_id", "key", "value", "updated_at") VALUES
426
+ ('${SNAPSHOT_SITE_ID}', 'SITE_NAME', 'Source', 1774009200);
427
+
428
+ INSERT INTO "post" (
429
+ "id", "site_id", "format", "status", "visibility", "title", "body", "body_html", "body_text",
430
+ "thread_id", "published_at", "last_activity_at", "created_at", "updated_at"
431
+ ) VALUES (
432
+ '${SNAPSHOT_POST_ID}', '${SNAPSHOT_SITE_ID}', 'note', 'published', 'public',
433
+ 'Snapshot post', 'Hello snapshot', '<p>Hello snapshot</p>', 'Hello snapshot',
434
+ '${SNAPSHOT_POST_ID}', 1774009200, 1774009200, 1774009200, 1774009200
435
+ );
436
+
437
+ INSERT INTO "media" (
438
+ "id", "site_id", "post_id", "filename", "original_name", "mime_type", "size", "storage_key",
439
+ "provider", "position", "media_kind", "created_at", "updated_at"
440
+ ) VALUES (
441
+ '${SNAPSHOT_MEDIA_ID}', '${SNAPSHOT_SITE_ID}', '${SNAPSHOT_POST_ID}',
442
+ '${SNAPSHOT_MEDIA_ID}.png', 'sample.png', 'image/png', 4, '${SNAPSHOT_MEDIA_KEY}',
443
+ 'local', 'a0', 'image', 1774009200, 1774009200
444
+ );
445
+ `);
446
+
447
+ targetSqlite.exec(`
448
+ INSERT INTO "site" ("id", "key", "status", "created_at", "updated_at")
449
+ VALUES ('${SNAPSHOT_SITE_ID}', '${SNAPSHOT_SITE_KEY}', 'active', 1774009000, 1774009000);
450
+ `);
451
+ } finally {
452
+ sourceSqlite.close();
453
+ targetSqlite.close();
454
+ }
455
+
456
+ process.env.DATABASE_URL = `file:${sourceDbPath}`;
457
+ process.env.LOCAL_STORAGE_PATH = sourceStoragePath;
458
+
459
+ vi.spyOn(console, "log").mockImplementation(() => {});
460
+ const { run: runExport } =
461
+ await import("../../../bin/commands/site/snapshot/export.js");
462
+ await runExport(["--output", snapshotPath, "--skip-objects"]);
463
+
464
+ process.env.DATABASE_URL = `file:${targetDbPath}`;
465
+ process.env.LOCAL_STORAGE_PATH = targetStoragePath;
466
+
467
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
468
+ const { run: runImport } =
469
+ await import("../../../bin/commands/site/snapshot/import.js");
470
+
471
+ await expect(
472
+ runImport(["--path", snapshotPath, "--replace"]),
473
+ ).rejects.toThrow(/missing storage objects/);
474
+
475
+ expect(warnSpy.mock.calls.flat().join("\n")).toContain(SNAPSHOT_MEDIA_KEY);
476
+ });
477
+
478
+ it("imports a --skip-objects snapshot when --allow-missing-objects is passed", async () => {
479
+ const root = await mkdtemp(
480
+ join(tmpdir(), "jant-site-snapshot-allow-missing-"),
481
+ );
482
+ tempDirs.push(root);
483
+
484
+ const sourceDbPath = join(root, "source.sqlite");
485
+ const sourceStoragePath = join(root, "source-media");
486
+ const targetDbPath = join(root, "target.sqlite");
487
+ const targetStoragePath = join(root, "target-media");
488
+ const snapshotPath = join(root, "snapshot");
489
+
490
+ await migrate({ DATABASE_URL: `file:${sourceDbPath}` } as Bindings);
491
+ await migrate({ DATABASE_URL: `file:${targetDbPath}` } as Bindings);
492
+
493
+ const sourceStorage = createLocalDriver({ rootPath: sourceStoragePath });
494
+ const targetStorage = createLocalDriver({ rootPath: targetStoragePath });
495
+ await sourceStorage.put(SNAPSHOT_MEDIA_KEY, new Uint8Array([1, 2, 3, 4]), {
496
+ contentType: "image/png",
497
+ });
498
+ // Simulate a shared bucket: target already has the file.
499
+ await targetStorage.put(SNAPSHOT_MEDIA_KEY, new Uint8Array([1, 2, 3, 4]), {
500
+ contentType: "image/png",
501
+ });
502
+
503
+ const sourceSqlite = new Database(sourceDbPath);
504
+ const targetSqlite = new Database(targetDbPath);
505
+ try {
506
+ sourceSqlite.exec(`
507
+ INSERT INTO "site" ("id", "key", "status", "created_at", "updated_at")
508
+ VALUES ('${SNAPSHOT_SITE_ID}', '${SNAPSHOT_SITE_KEY}', 'active', 1774009100, 1774009100);
509
+
510
+ INSERT INTO "site_setting" ("site_id", "key", "value", "updated_at") VALUES
511
+ ('${SNAPSHOT_SITE_ID}', 'SITE_NAME', 'Source', 1774009200);
512
+
513
+ INSERT INTO "post" (
514
+ "id", "site_id", "format", "status", "visibility", "title", "body", "body_html", "body_text",
515
+ "thread_id", "published_at", "last_activity_at", "created_at", "updated_at"
516
+ ) VALUES (
517
+ '${SNAPSHOT_POST_ID}', '${SNAPSHOT_SITE_ID}', 'note', 'published', 'public',
518
+ 'Snapshot post', 'Hello snapshot', '<p>Hello snapshot</p>', 'Hello snapshot',
519
+ '${SNAPSHOT_POST_ID}', 1774009200, 1774009200, 1774009200, 1774009200
520
+ );
521
+
522
+ INSERT INTO "media" (
523
+ "id", "site_id", "post_id", "filename", "original_name", "mime_type", "size", "storage_key",
524
+ "provider", "position", "media_kind", "created_at", "updated_at"
525
+ ) VALUES (
526
+ '${SNAPSHOT_MEDIA_ID}', '${SNAPSHOT_SITE_ID}', '${SNAPSHOT_POST_ID}',
527
+ '${SNAPSHOT_MEDIA_ID}.png', 'sample.png', 'image/png', 4, '${SNAPSHOT_MEDIA_KEY}',
528
+ 'local', 'a0', 'image', 1774009200, 1774009200
529
+ );
530
+ `);
531
+
532
+ targetSqlite.exec(`
533
+ INSERT INTO "site" ("id", "key", "status", "created_at", "updated_at")
534
+ VALUES ('${SNAPSHOT_SITE_ID}', '${SNAPSHOT_SITE_KEY}', 'active', 1774009000, 1774009000);
535
+ `);
536
+ } finally {
537
+ sourceSqlite.close();
538
+ targetSqlite.close();
539
+ }
540
+
541
+ process.env.DATABASE_URL = `file:${sourceDbPath}`;
542
+ process.env.LOCAL_STORAGE_PATH = sourceStoragePath;
543
+
544
+ vi.spyOn(console, "log").mockImplementation(() => {});
545
+ const { run: runExport } =
546
+ await import("../../../bin/commands/site/snapshot/export.js");
547
+ await runExport(["--output", snapshotPath, "--skip-objects"]);
548
+
549
+ process.env.DATABASE_URL = `file:${targetDbPath}`;
550
+ process.env.LOCAL_STORAGE_PATH = targetStoragePath;
551
+
552
+ vi.spyOn(console, "warn").mockImplementation(() => {});
553
+ const { run: runImport } =
554
+ await import("../../../bin/commands/site/snapshot/import.js");
555
+ await runImport([
556
+ "--path",
557
+ snapshotPath,
558
+ "--replace",
559
+ "--allow-missing-objects",
560
+ ]);
561
+
562
+ const verifySqlite = new Database(targetDbPath, { readonly: true });
563
+ try {
564
+ const mediaRow = verifySqlite
565
+ .prepare(
566
+ `SELECT "storage_key" FROM "media" WHERE "id" = '${SNAPSHOT_MEDIA_ID}'`,
567
+ )
568
+ .get() as { storage_key: string } | undefined;
569
+ expect(mediaRow?.storage_key).toBe(SNAPSHOT_MEDIA_KEY);
570
+ } finally {
571
+ verifySqlite.close();
572
+ }
573
+
574
+ // Target storage still has the pre-existing file (we didn't try to upload).
575
+ expect(await targetStorage.get(SNAPSHOT_MEDIA_KEY)).not.toBeNull();
576
+ });
577
+
314
578
  it("requires --replace for snapshot import", async () => {
315
579
  const root = await mkdtemp(join(tmpdir(), "jant-site-snapshot-replace-"));
316
580
  tempDirs.push(root);
@@ -328,16 +592,11 @@ describe("jant site snapshot export/import", () => {
328
592
  {
329
593
  format: "jant-site-snapshot",
330
594
  version: 1,
331
- scope: "content",
332
595
  },
333
596
  null,
334
597
  2,
335
598
  ),
336
599
  ),
337
- writeFile(
338
- join(snapshotPath, "storage-manifest.json"),
339
- JSON.stringify({ version: 1, objects: [] }, null, 2),
340
- ),
341
600
  writeFile(join(snapshotPath, "db.sql"), ""),
342
601
  ]);
343
602
 
@@ -414,6 +673,24 @@ describe("jant site snapshot export/import", () => {
414
673
  'local', 1, 1, 'Sample alt', 'a0', '${SNAPSHOT_POSTER_KEY}', 'image',
415
674
  1774009200, 1774009200
416
675
  );
676
+
677
+ INSERT INTO "media" (
678
+ "id", "site_id", "post_id", "filename", "original_name", "mime_type", "size", "storage_key",
679
+ "provider", "position", "media_kind", "created_at", "updated_at"
680
+ ) VALUES (
681
+ '${SNAPSHOT_AVATAR_MEDIA_ID}', '${SNAPSHOT_SITE_ID}', NULL,
682
+ '${SNAPSHOT_AVATAR_MEDIA_ID}.png', 'avatar.png', 'image/png', 3, '${SNAPSHOT_AVATAR_KEY}',
683
+ 'local', 'a0', 'image', 1774009202, 1774009202
684
+ );
685
+
686
+ INSERT INTO "media" (
687
+ "id", "site_id", "post_id", "filename", "original_name", "mime_type", "size", "storage_key",
688
+ "provider", "position", "media_kind", "created_at", "updated_at"
689
+ ) VALUES (
690
+ '${SNAPSHOT_APPLE_TOUCH_MEDIA_ID}', '${SNAPSHOT_SITE_ID}', NULL,
691
+ 'apple-touch-icon.png', 'apple-touch-icon.png', 'image/png', 3, '${SNAPSHOT_APPLE_TOUCH_KEY}',
692
+ 'local', 'a0', 'image', 1774009203, 1774009203
693
+ );
417
694
  `);
418
695
 
419
696
  targetSqlite.exec(`
@@ -562,6 +839,24 @@ describe("jant site snapshot export/import", () => {
562
839
  'local', 1, 1, 'Sample alt', 'a0', '${SNAPSHOT_POSTER_KEY}', 'image',
563
840
  1774009200, 1774009200
564
841
  );
842
+
843
+ INSERT INTO "media" (
844
+ "id", "site_id", "post_id", "filename", "original_name", "mime_type", "size", "storage_key",
845
+ "provider", "position", "media_kind", "created_at", "updated_at"
846
+ ) VALUES (
847
+ '${SNAPSHOT_AVATAR_MEDIA_ID}', '${SNAPSHOT_SITE_ID}', NULL,
848
+ '${SNAPSHOT_AVATAR_MEDIA_ID}.png', 'avatar.png', 'image/png', 3, '${SNAPSHOT_AVATAR_KEY}',
849
+ 'local', 'a0', 'image', 1774009202, 1774009202
850
+ );
851
+
852
+ INSERT INTO "media" (
853
+ "id", "site_id", "post_id", "filename", "original_name", "mime_type", "size", "storage_key",
854
+ "provider", "position", "media_kind", "created_at", "updated_at"
855
+ ) VALUES (
856
+ '${SNAPSHOT_APPLE_TOUCH_MEDIA_ID}', '${SNAPSHOT_SITE_ID}', NULL,
857
+ 'apple-touch-icon.png', 'apple-touch-icon.png', 'image/png', 3, '${SNAPSHOT_APPLE_TOUCH_KEY}',
858
+ 'local', 'a0', 'image', 1774009203, 1774009203
859
+ );
565
860
  `);
566
861
 
567
862
  targetSqlite.exec(`
@@ -65,7 +65,6 @@ describe("site CLI token env", () => {
65
65
  vi.spyOn(console, "log").mockImplementation(() => {});
66
66
 
67
67
  await runSiteExport([
68
- "--url",
69
68
  "https://example.com",
70
69
  "--output",
71
70
  outputPath,
@@ -95,7 +94,6 @@ describe("site CLI token env", () => {
95
94
 
96
95
  await expect(
97
96
  runImportSite([
98
- "--url",
99
97
  "https://example.com",
100
98
  "--path",
101
99
  "/definitely-missing-jant-import-source",
@@ -106,9 +104,7 @@ describe("site CLI token env", () => {
106
104
  "Path not found: /definitely-missing-jant-import-source",
107
105
  );
108
106
  expect(errorSpy).not.toHaveBeenCalledWith(
109
- expect.stringContaining(
110
- "remote import requires JANT_API_TOKEN or --token",
111
- ),
107
+ expect.stringContaining("site import requires JANT_API_TOKEN or --token"),
112
108
  );
113
109
  });
114
110
 
@@ -123,7 +119,6 @@ describe("site CLI token env", () => {
123
119
 
124
120
  await expect(
125
121
  runImportSite([
126
- "--url",
127
122
  "https://example.com",
128
123
  "--path",
129
124
  "/definitely-missing-jant-import-source",
@@ -131,7 +126,7 @@ describe("site CLI token env", () => {
131
126
  ).rejects.toThrow("process.exit:1");
132
127
 
133
128
  expect(errorSpy).toHaveBeenCalledWith(
134
- "Error: remote import requires JANT_API_TOKEN or --token (unless using --dry-run)",
129
+ "Error: site import requires JANT_API_TOKEN or --token (unless using --dry-run)",
135
130
  );
136
131
  });
137
132
  });