@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
@@ -5,7 +5,6 @@ import {
5
5
  readFile,
6
6
  readdir,
7
7
  rm,
8
- stat,
9
8
  writeFile,
10
9
  } from "node:fs/promises";
11
10
  import { tmpdir } from "node:os";
@@ -24,15 +23,14 @@ import {
24
23
  buildSnapshotStorageQuery,
25
24
  collectSnapshotObjects,
26
25
  getSnapshotSelectSql,
27
- sha256File,
28
26
  SNAPSHOT_TABLES,
29
27
  snapshotObjectPath,
30
28
  } from "../../../lib/site-snapshot.js";
31
29
  import { resolveCliSite } from "../../../lib/site-selection.js";
32
30
  import { dumpDatabaseToSql } from "../../../lib/sql-export.js";
33
31
  import {
32
+ bootstrapCliRuntime,
34
33
  getCliRuntimeLabel,
35
- resolveCliRuntime,
36
34
  } from "../../../lib/runtime-target.js";
37
35
  import { resolveWranglerVarString } from "../../../lib/wrangler-config.js";
38
36
 
@@ -99,8 +97,12 @@ async function assertWritableOutput(outputPath, force) {
99
97
 
100
98
  async function createNodeExportContext() {
101
99
  const nodeDatabase = await openNodeDatabase(process.env);
102
- const { createNodeCliRuntime } = await loadNodeRuntime();
103
- const runtime = await createNodeCliRuntime(nodeDatabase.bindings);
100
+ // Only need the storage driver — `createNodeCliRuntime` would also resolve
101
+ // the current site, which (a) is redundant with the bin-level resolveCliSite
102
+ // call below and (b) prints a generic "/setup first" error when the
103
+ // snapshot's own error path is more informative.
104
+ const { createStorageDriver } = await loadNodeRuntime();
105
+ const storage = createStorageDriver(nodeDatabase.bindings);
104
106
 
105
107
  return {
106
108
  dialect: nodeDatabase.database.dialect,
@@ -111,11 +113,11 @@ async function createNodeExportContext() {
111
113
  return nodeDatabase.query(sql);
112
114
  },
113
115
  async downloadObject(key, filePath) {
114
- if (!runtime.storage) {
116
+ if (!storage) {
115
117
  throw new Error("Snapshot export requires configured storage.");
116
118
  }
117
119
 
118
- const object = await runtime.storage.get(key);
120
+ const object = await storage.get(key);
119
121
  if (!object?.body) {
120
122
  throw new Error(`Storage object not found: ${key}`);
121
123
  }
@@ -178,6 +180,7 @@ export async function run(argv) {
178
180
  host: { type: "string" },
179
181
  help: { type: "boolean", short: "h" },
180
182
  local: { type: "boolean", default: false },
183
+ node: { type: "boolean", default: false },
181
184
  output: {
182
185
  type: "string",
183
186
  short: "o",
@@ -187,13 +190,14 @@ export async function run(argv) {
187
190
  "persist-to": { type: "string" },
188
191
  remote: { type: "boolean", default: false },
189
192
  site: { type: "string" },
193
+ "skip-objects": { type: "boolean", default: false },
190
194
  url: { type: "string" },
191
195
  },
192
196
  });
193
197
 
194
198
  if (values.help) {
195
199
  console.log(
196
- "Usage: jant site snapshot export [--local | --remote] [--output <dir|zip>]",
200
+ "Usage: jant site snapshot export [--local | --remote | --node] [--output <dir|zip>]",
197
201
  );
198
202
  console.log("");
199
203
  console.log(
@@ -205,6 +209,9 @@ export async function run(argv) {
205
209
  " --local Force local D1 instead of DATABASE_URL",
206
210
  );
207
211
  console.log(" --remote Export from remote D1");
212
+ console.log(
213
+ " --node Force Node runtime even if DATABASE_URL is unset",
214
+ );
208
215
  console.log(
209
216
  " --output, -o Output directory or .zip file (default: jant-site-snapshot)",
210
217
  );
@@ -227,14 +234,33 @@ export async function run(argv) {
227
234
  console.log(
228
235
  " --persist-to Local D1/R2 state directory override",
229
236
  );
237
+ console.log(
238
+ " --skip-objects Skip downloading storage objects. The archive only contains meta.json and db.sql.",
239
+ );
240
+ console.log(
241
+ " Only safe when the import target's storage already has the same keys",
242
+ );
243
+ console.log(
244
+ " (e.g. moving between Workers that share an R2 bucket). Otherwise the",
245
+ );
246
+ console.log(
247
+ " imported site will be missing media — pair with `--allow-missing-objects`",
248
+ );
249
+ console.log(" on import.");
230
250
  console.log("");
231
251
  console.log(
232
- "If DATABASE_URL or DATA_DIR is set and no runtime flag is passed, this command uses the Node database runtime and the configured storage driver.",
252
+ "`.env.node` next to your project (or in packages/core/) is auto-loaded.",
253
+ );
254
+ console.log(
255
+ "If DATABASE_URL or DATA_DIR is then set and no runtime flag is passed,",
256
+ );
257
+ console.log(
258
+ "this command uses the Node database runtime and configured storage driver.",
233
259
  );
234
260
  process.exit(0);
235
261
  }
236
262
 
237
- const runtime = resolveCliRuntime(values);
263
+ const { runtime } = bootstrapCliRuntime(values);
238
264
  const outputPath = resolve(process.cwd(), values.output);
239
265
  const shouldZip = isZipPath(outputPath);
240
266
  const scratchDir = shouldZip
@@ -259,6 +285,7 @@ export async function run(argv) {
259
285
  url: values.url,
260
286
  });
261
287
 
288
+ console.log(`Dumping database (${SNAPSHOT_TABLES.length} tables)...`);
262
289
  const dbSql = await dumpDatabaseToSql(
263
290
  {
264
291
  query(sql) {
@@ -275,55 +302,41 @@ export async function run(argv) {
275
302
  getSnapshotSelectSql(tableName, site.id),
276
303
  ]),
277
304
  ),
305
+ onProgress: ({ index, total, table }) => {
306
+ console.log(` [${index}/${total}] ${table}`);
307
+ },
278
308
  },
279
309
  );
280
310
 
311
+ console.log("Listing storage objects...");
281
312
  const objectRows = await context.query(buildSnapshotStorageQuery(site.id));
282
313
  const objects = collectSnapshotObjects(objectRows);
283
- const manifestObjects = [];
284
314
 
285
315
  await writeFile(join(scratchDir, "db.sql"), dbSql);
286
316
 
287
- if (objects.length > 0) {
288
- console.log(`Downloading ${objects.length} referenced object(s)...`);
289
- }
317
+ if (values["skip-objects"]) {
318
+ if (objects.length > 0) {
319
+ console.log(
320
+ `--skip-objects: leaving ${objects.length} referenced object(s) out of the archive.`,
321
+ );
322
+ }
323
+ } else {
324
+ if (objects.length > 0) {
325
+ console.log(`Downloading ${objects.length} referenced object(s)...`);
326
+ }
290
327
 
291
- for (const [index, object] of objects.entries()) {
292
- const relativeObjectPath = snapshotObjectPath(object.key);
293
- const absoluteObjectPath = join(scratchDir, relativeObjectPath);
294
- console.log(`[${index + 1}/${objects.length}] ${object.key}`);
295
- await context.downloadObject(object.key, absoluteObjectPath);
296
- const fileStat = await stat(absoluteObjectPath);
297
- manifestObjects.push({
298
- key: object.key,
299
- file: relativeObjectPath,
300
- contentType: object.contentType || undefined,
301
- size: fileStat.size,
302
- sha256: await sha256File(absoluteObjectPath),
303
- });
328
+ for (const [index, object] of objects.entries()) {
329
+ const relativeObjectPath = snapshotObjectPath(object.key);
330
+ const absoluteObjectPath = join(scratchDir, relativeObjectPath);
331
+ console.log(`[${index + 1}/${objects.length}] ${object.key}`);
332
+ await context.downloadObject(object.key, absoluteObjectPath);
333
+ }
304
334
  }
305
335
 
306
336
  await writeFile(
307
337
  join(scratchDir, "meta.json"),
308
338
  JSON.stringify(
309
- buildSnapshotMeta(
310
- {
311
- runtime,
312
- label: getCliRuntimeLabel(runtime),
313
- },
314
- site,
315
- ),
316
- null,
317
- 2,
318
- ) + "\n",
319
- );
320
- await writeFile(
321
- join(scratchDir, "storage-manifest.json"),
322
- JSON.stringify(
323
- {
324
- version: 1,
325
- objects: manifestObjects,
326
- },
339
+ buildSnapshotMeta(site, { dialect: context.dialect }),
327
340
  null,
328
341
  2,
329
342
  ) + "\n",
@@ -16,24 +16,25 @@ import { loadNodeRuntime } from "../../../lib/load-node-runtime.js";
16
16
  import { openNodeDatabase } from "../../../lib/node-database.js";
17
17
  import { deleteR2Object, uploadR2Object } from "../../../lib/r2-query.js";
18
18
  import {
19
- assertSnapshotManifest,
19
+ assertSnapshotDialectMatches,
20
20
  assertSnapshotMeta,
21
21
  buildReplaceSql,
22
22
  buildSnapshotStorageQuery,
23
23
  collectSnapshotObjects,
24
+ enumerateSnapshotObjectFiles,
25
+ extractMediaStorageKeysFromDumpSql,
24
26
  getSnapshotBootstrapSite,
25
27
  normalizeD1Sql,
26
- remapSnapshotManifestObjects,
28
+ remapSnapshotObjectKey,
27
29
  rewriteLegacySnapshotSql,
28
30
  rewriteSnapshotSiteIdentifiers,
29
- sha256File,
30
31
  validateSnapshotTargetSite,
31
32
  } from "../../../lib/site-snapshot.js";
32
33
  import {
33
34
  getCliSiteResolutionMode,
34
35
  resolveCliSite,
35
36
  } from "../../../lib/site-selection.js";
36
- import { resolveCliRuntime } from "../../../lib/runtime-target.js";
37
+ import { bootstrapCliRuntime } from "../../../lib/runtime-target.js";
37
38
 
38
39
  function isZipPath(filePath) {
39
40
  return filePath.toLowerCase().endsWith(".zip");
@@ -52,10 +53,15 @@ function createWranglerOptions(values) {
52
53
 
53
54
  async function createNodeImportContext() {
54
55
  const nodeDatabase = await openNodeDatabase(process.env);
55
- const { createNodeCliRuntime } = await loadNodeRuntime();
56
- const runtime = await createNodeCliRuntime(nodeDatabase.bindings);
56
+ // Only need the storage driver — `createNodeCliRuntime` would also resolve
57
+ // the current site, which (a) is redundant with the bin-level resolveCliSite
58
+ // call below and (b) prints a generic "/setup first" error when the
59
+ // snapshot's own error path is more informative.
60
+ const { createStorageDriver } = await loadNodeRuntime();
61
+ const storage = createStorageDriver(nodeDatabase.bindings);
57
62
 
58
63
  return {
64
+ dialect: nodeDatabase.database.dialect,
59
65
  async close() {
60
66
  await nodeDatabase.close();
61
67
  },
@@ -66,20 +72,20 @@ async function createNodeImportContext() {
66
72
  await nodeDatabase.execute(sql);
67
73
  },
68
74
  async uploadObject(key, filePath, contentType) {
69
- if (!runtime.storage) {
75
+ if (!storage) {
70
76
  throw new Error("Snapshot import requires configured storage.");
71
77
  }
72
78
 
73
79
  const bytes = new Uint8Array(await readFile(filePath));
74
- await runtime.storage.put(key, bytes, {
80
+ await storage.put(key, bytes, {
75
81
  contentType: contentType || undefined,
76
82
  });
77
83
  },
78
84
  async deleteObject(key) {
79
- if (!runtime.storage) {
85
+ if (!storage) {
80
86
  return;
81
87
  }
82
- await runtime.storage.delete(key);
88
+ await storage.delete(key);
83
89
  },
84
90
  };
85
91
  }
@@ -88,6 +94,7 @@ function createD1ImportContext(runtime, values) {
88
94
  const wranglerOptions = createWranglerOptions(values);
89
95
 
90
96
  return {
97
+ dialect: "sqlite",
91
98
  async close() {},
92
99
  async query(sql) {
93
100
  return queryD1(sql, runtime, wranglerOptions);
@@ -155,23 +162,6 @@ async function readSnapshotJson(rootDir, filename) {
155
162
  return JSON.parse(await readFile(absolutePath, "utf-8"));
156
163
  }
157
164
 
158
- async function validateManifestObjects(rootDir, manifest) {
159
- for (const object of manifest.objects) {
160
- const absolutePath = join(rootDir, object.file);
161
- const fileStat = await stat(absolutePath).catch(() => null);
162
- if (!fileStat?.isFile()) {
163
- throw new Error(`Snapshot object file is missing: ${object.file}`);
164
- }
165
-
166
- const actualHash = await sha256File(absolutePath);
167
- if (actualHash !== object.sha256) {
168
- throw new Error(
169
- `Snapshot object checksum mismatch for ${object.key}: expected ${object.sha256}, got ${actualHash}`,
170
- );
171
- }
172
- }
173
- }
174
-
175
165
  export async function run(argv) {
176
166
  const { values } = parseArgs({
177
167
  args: argv,
@@ -184,12 +174,14 @@ export async function run(argv) {
184
174
  host: { type: "string" },
185
175
  help: { type: "boolean", short: "h" },
186
176
  local: { type: "boolean", default: false },
177
+ node: { type: "boolean", default: false },
187
178
  path: { type: "string", default: "." },
188
179
  "path-prefix": { type: "string" },
189
180
  "persist-to": { type: "string" },
190
181
  remote: { type: "boolean", default: false },
191
182
  replace: { type: "boolean", default: false },
192
183
  "remap-site": { type: "boolean", default: false },
184
+ "allow-missing-objects": { type: "boolean", default: false },
193
185
  site: { type: "string" },
194
186
  url: { type: "string" },
195
187
  },
@@ -197,7 +189,7 @@ export async function run(argv) {
197
189
 
198
190
  if (values.help) {
199
191
  console.log(
200
- "Usage: jant site snapshot import --path <dir|zip> --replace [--local | --remote]",
192
+ "Usage: jant site snapshot import --path <dir|zip> --replace [--local | --remote | --node]",
201
193
  );
202
194
  console.log("");
203
195
  console.log(
@@ -219,6 +211,9 @@ export async function run(argv) {
219
211
  " --local Force local D1 instead of DATABASE_URL",
220
212
  );
221
213
  console.log(" --remote Import into remote D1");
214
+ console.log(
215
+ " --node Force Node runtime even if DATABASE_URL is unset",
216
+ );
222
217
  console.log(
223
218
  " --config Wrangler config file (default: wrangler.toml)",
224
219
  );
@@ -236,6 +231,27 @@ export async function run(argv) {
236
231
  console.log(
237
232
  " --remap-site Rewrite snapshot site_id and storage keys to the resolved target site",
238
233
  );
234
+ console.log(
235
+ " --allow-missing-objects Continue importing even when objects/ is missing files referenced by db.sql.",
236
+ );
237
+ console.log(
238
+ " Use this when the target storage already has those keys (e.g. a snapshot",
239
+ );
240
+ console.log(
241
+ " exported with --skip-objects between sites that share an R2 bucket).",
242
+ );
243
+ console.log(
244
+ " Without this flag, import aborts before applying db.sql and prints the",
245
+ );
246
+ console.log(" missing key list.");
247
+ console.log("");
248
+ console.log(
249
+ "`.env.node` next to your project (or in packages/core/) is auto-loaded.",
250
+ );
251
+ console.log(
252
+ "If DATABASE_URL or DATA_DIR is then set and no runtime flag is passed,",
253
+ );
254
+ console.log("this command uses the Node database runtime.");
239
255
  console.log("");
240
256
  console.log(
241
257
  "In single-site mode, snapshot imports automatically remap to the only initialized site.",
@@ -253,7 +269,7 @@ export async function run(argv) {
253
269
  );
254
270
  }
255
271
 
256
- const runtime = resolveCliRuntime(values);
272
+ const { runtime } = bootstrapCliRuntime(values);
257
273
  const inputPath = resolve(process.cwd(), values.path);
258
274
  const materialized = await materializeSnapshotInput(inputPath);
259
275
  const context =
@@ -263,16 +279,10 @@ export async function run(argv) {
263
279
 
264
280
  try {
265
281
  const meta = await readSnapshotJson(materialized.rootDir, "meta.json");
266
- const rawManifest = await readSnapshotJson(
267
- materialized.rootDir,
268
- "storage-manifest.json",
269
- );
270
282
  assertSnapshotMeta(meta);
283
+ assertSnapshotDialectMatches(meta, context.dialect);
271
284
  const explicitRemap = values["remap-site"] === true;
272
285
  const snapshotSite = getSnapshotBootstrapSite(meta);
273
- const originalManifest = rawManifest;
274
- assertSnapshotManifest(originalManifest);
275
- await validateManifestObjects(materialized.rootDir, originalManifest);
276
286
  const resolutionMode = getCliSiteResolutionMode(process.env);
277
287
 
278
288
  const { site: targetSite } = await resolveCliSite(context, {
@@ -307,17 +317,63 @@ export async function run(argv) {
307
317
  );
308
318
  }
309
319
 
310
- const manifest = shouldRemapSite
311
- ? remapSnapshotManifestObjects(
312
- originalManifest,
313
- snapshotSite?.id ?? "",
314
- targetSite.id,
315
- )
316
- : originalManifest;
320
+ const sourceSiteId = shouldRemapSite ? (snapshotSite?.id ?? "") : "";
321
+ const objectFiles = await enumerateSnapshotObjectFiles(
322
+ materialized.rootDir,
323
+ );
324
+ const snapshotObjects = objectFiles.map((entry) => ({
325
+ filePath: entry.filePath,
326
+ contentType: entry.contentType,
327
+ key: shouldRemapSite
328
+ ? remapSnapshotObjectKey(entry.key, sourceSiteId, targetSite.id)
329
+ : entry.key,
330
+ }));
331
+ const snapshotKeys = new Set(snapshotObjects.map((object) => object.key));
332
+
333
+ const rawDbSql = await readFile(
334
+ join(materialized.rootDir, "db.sql"),
335
+ "utf-8",
336
+ );
317
337
 
318
- const snapshotKeys = new Set(
319
- manifest.objects.map((object) => String(object.key)),
338
+ // Preflight: every storage_key/poster_key referenced by db.sql must have
339
+ // a corresponding file in objects/, or we'll end up with broken media
340
+ // unless the target storage already has it. Default to abort; let the
341
+ // user override with --allow-missing-objects when they know the files
342
+ // already live in the target bucket (typical --skip-objects flow).
343
+ const sourceKeysInObjects = new Set(objectFiles.map((entry) => entry.key));
344
+ const expectedSourceKeys = extractMediaStorageKeysFromDumpSql(
345
+ rawDbSql,
346
+ snapshotSite?.id ?? "",
320
347
  );
348
+ const missingFromObjects = [...expectedSourceKeys]
349
+ .filter((key) => !sourceKeysInObjects.has(key))
350
+ .sort();
351
+
352
+ if (missingFromObjects.length > 0) {
353
+ const display = missingFromObjects.slice(0, 10);
354
+ const remainder = missingFromObjects.length - display.length;
355
+ console.warn(
356
+ `\n${missingFromObjects.length} object(s) referenced by db.sql are missing from the snapshot's objects/:`,
357
+ );
358
+ for (const key of display) {
359
+ console.warn(` ${key}`);
360
+ }
361
+ if (remainder > 0) {
362
+ console.warn(` …and ${remainder} more`);
363
+ }
364
+
365
+ if (!values["allow-missing-objects"]) {
366
+ throw new Error(
367
+ "Snapshot is missing storage objects referenced by db.sql. " +
368
+ "Pass --allow-missing-objects to import anyway (only safe when the target storage already has these keys).",
369
+ );
370
+ }
371
+
372
+ console.warn(
373
+ "Continuing with --allow-missing-objects; the target storage must already have these keys or media references will 404.\n",
374
+ );
375
+ }
376
+
321
377
  const currentObjectRows = await context.query(
322
378
  buildSnapshotStorageQuery(targetSite.id),
323
379
  );
@@ -325,18 +381,14 @@ export async function run(argv) {
325
381
  collectSnapshotObjects(currentObjectRows).map((object) => object.key),
326
382
  );
327
383
 
328
- for (const object of manifest.objects) {
384
+ for (const object of snapshotObjects) {
329
385
  await context.uploadObject(
330
386
  object.key,
331
- join(materialized.rootDir, object.file),
332
- typeof object.contentType === "string" ? object.contentType : "",
387
+ object.filePath,
388
+ object.contentType || "",
333
389
  );
334
390
  }
335
391
 
336
- const rawDbSql = await readFile(
337
- join(materialized.rootDir, "db.sql"),
338
- "utf-8",
339
- );
340
392
  const dbSql = snapshotSite
341
393
  ? shouldRemapSite
342
394
  ? rewriteSnapshotSiteIdentifiers(
@@ -0,0 +1,100 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ function stripSurroundingQuotes(value) {
6
+ if (value.length < 2) return value;
7
+ const first = value[0];
8
+ const last = value[value.length - 1];
9
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
10
+ return value.slice(1, -1);
11
+ }
12
+ return value;
13
+ }
14
+
15
+ function fileExists(envPath) {
16
+ try {
17
+ readFileSync(envPath, "utf8");
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Locate `.env.node` for CLI auto-load. Searches, in order:
26
+ * 1. `<cwd>/.env.node` — user's site directory
27
+ * 2. `<bin>/../../.env.node` — `packages/core/.env.node` (in-repo dev)
28
+ *
29
+ * Returns the first existing path, or `null` if none is found.
30
+ */
31
+ export function findNodeEnvPath(cwd = process.cwd()) {
32
+ const candidates = [
33
+ resolve(cwd, ".env.node"),
34
+ resolve(dirname(fileURLToPath(import.meta.url)), "../../.env.node"),
35
+ ];
36
+
37
+ for (const candidate of candidates) {
38
+ if (fileExists(candidate)) {
39
+ return candidate;
40
+ }
41
+ }
42
+
43
+ return null;
44
+ }
45
+
46
+ /**
47
+ * Parse the .env.node file and assign keys into `env`. Existing values in
48
+ * `env` are preserved (already-exported shell vars win over file values).
49
+ *
50
+ * Returns a result object useful for debug logging:
51
+ * { envPath, found, assignedKeys, skippedKeys }
52
+ */
53
+ export function loadNodeEnvFile(envPath, env = process.env) {
54
+ const result = {
55
+ envPath,
56
+ found: false,
57
+ assignedKeys: [],
58
+ skippedKeys: [],
59
+ };
60
+
61
+ let content;
62
+ try {
63
+ content = readFileSync(envPath, "utf8");
64
+ } catch {
65
+ return result;
66
+ }
67
+
68
+ result.found = true;
69
+ for (const line of content.split("\n")) {
70
+ const trimmed = line.trim();
71
+ if (!trimmed || trimmed.startsWith("#")) continue;
72
+ const eqIdx = trimmed.indexOf("=");
73
+ if (eqIdx < 1) continue;
74
+ const key = trimmed.slice(0, eqIdx).trim();
75
+ const value = stripSurroundingQuotes(trimmed.slice(eqIdx + 1).trim());
76
+ if (key in env) {
77
+ result.skippedKeys.push(key);
78
+ continue;
79
+ }
80
+ env[key] = value;
81
+ result.assignedKeys.push(key);
82
+ }
83
+
84
+ return result;
85
+ }
86
+
87
+ /**
88
+ * Auto-locate and load `.env.node` for any DB-touching CLI command.
89
+ *
90
+ * Always called before `resolveCliRuntime()`, so DATABASE_URL / DATA_DIR
91
+ * defined in `.env.node` make `--node` (or auto-detect) work without
92
+ * requiring the user to source the file manually.
93
+ */
94
+ export function autoloadNodeEnv(env = process.env) {
95
+ const envPath = findNodeEnvPath();
96
+ if (!envPath) {
97
+ return { envPath: null, found: false, assignedKeys: [], skippedKeys: [] };
98
+ }
99
+ return loadNodeEnvFile(envPath, env);
100
+ }
@@ -1,3 +1,6 @@
1
+ import { autoloadNodeEnv } from "./node-env.js";
2
+ import { resolveDatabaseDialect } from "./node-sqlite.js";
3
+
1
4
  export function resolveCliRuntime(values, env = process.env) {
2
5
  const flags = [values.local, values.remote, values.node].filter(Boolean);
3
6
  if (flags.length > 1) {
@@ -36,3 +39,64 @@ export function getCliRuntimeLabel(runtime) {
36
39
  return "Node database";
37
40
  }
38
41
  }
42
+
43
+ function describeNodeTarget(env) {
44
+ const databaseUrl = env.DATABASE_URL;
45
+ if (typeof databaseUrl === "string" && databaseUrl.length > 0) {
46
+ const dialect = resolveDatabaseDialect(databaseUrl);
47
+ if (dialect === "sqlite") {
48
+ return `sqlite ${databaseUrl}`;
49
+ }
50
+
51
+ try {
52
+ const parsed = new URL(databaseUrl);
53
+ const protocol = parsed.protocol.replace(/:$/, "");
54
+ const host = parsed.hostname || "?";
55
+ const port = parsed.port ? `:${parsed.port}` : "";
56
+ const database = parsed.pathname.replace(/^\/+/, "") || "?";
57
+ return `${protocol} ${host}${port}/${database}`;
58
+ } catch {
59
+ return "<invalid DATABASE_URL>";
60
+ }
61
+ }
62
+
63
+ if (typeof env.DATA_DIR === "string" && env.DATA_DIR.length > 0) {
64
+ return `DATA_DIR=${env.DATA_DIR}`;
65
+ }
66
+
67
+ return "<unset>";
68
+ }
69
+
70
+ export function formatRuntimeBanner(runtime, env = process.env) {
71
+ switch (runtime) {
72
+ case "node":
73
+ return `[jant] target = node (${describeNodeTarget(env)})`;
74
+ case "d1-remote":
75
+ return "[jant] target = remote D1 (wrangler)";
76
+ case "d1-local":
77
+ default:
78
+ return "[jant] target = local D1 (wrangler)";
79
+ }
80
+ }
81
+
82
+ /**
83
+ * One-call helper for DB-touching CLI commands:
84
+ * 1. Auto-load `.env.node` (so DATABASE_URL/DATA_DIR work without sourcing).
85
+ * 2. Resolve the runtime from flags and env.
86
+ * 3. Print a one-line banner so the user immediately sees which target
87
+ * was picked, instead of finding out minutes later when something fails.
88
+ *
89
+ * Pass `{ silent: true }` to suppress the banner (e.g. in tests or when the
90
+ * caller wants to print its own header first).
91
+ */
92
+ export function bootstrapCliRuntime(values, options = {}) {
93
+ const env = options.env ?? process.env;
94
+ const envLoad = autoloadNodeEnv(env);
95
+ const runtime = resolveCliRuntime(values, env);
96
+
97
+ if (!options.silent) {
98
+ console.log(formatRuntimeBanner(runtime, env));
99
+ }
100
+
101
+ return { runtime, envLoad };
102
+ }