@jant/core 0.3.46 → 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 (109) 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 +60 -267
  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-DB-P66E5.js → app-3REcR-3U.js} +331 -189
  15. package/dist/app-B67XOEyo.js +6 -0
  16. package/dist/client/.vite/manifest.json +2 -2
  17. package/dist/client/_assets/{client-auth-BLCUje4M.js → client-auth-Ce5WEAVS.js} +102 -49
  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/client/components/__tests__/jant-settings-avatar.test.ts +2 -0
  27. package/src/client/components/__tests__/jant-settings-general.test.ts +70 -0
  28. package/src/client/components/jant-settings-general.ts +164 -22
  29. package/src/client/components/settings-types.ts +4 -6
  30. package/src/client-auth.ts +1 -1
  31. package/src/db/__tests__/demo-canonical-snapshot.test.ts +1 -1
  32. package/src/db/__tests__/migration-rehearsal.test.ts +2 -5
  33. package/src/db/backfills/0004_register_apple_touch_media_rows.sql +65 -0
  34. package/src/db/migrations/0021_thankful_phalanx.sql +16 -0
  35. package/src/db/migrations/meta/0021_snapshot.json +2121 -0
  36. package/src/db/migrations/meta/_journal.json +7 -0
  37. package/src/db/migrations/pg/0019_gray_natasha_romanoff.sql +20 -0
  38. package/src/db/migrations/pg/meta/0019_snapshot.json +2718 -0
  39. package/src/db/migrations/pg/meta/_journal.json +7 -0
  40. package/src/db/pg/schema.ts +21 -26
  41. package/src/db/rehearsal-fixtures/demo-current.json +1 -1
  42. package/src/db/schema.ts +16 -20
  43. package/src/i18n/__tests__/middleware.test.ts +43 -1
  44. package/src/i18n/coverage.generated.ts +17 -0
  45. package/src/i18n/i18n.ts +18 -2
  46. package/src/i18n/index.ts +3 -0
  47. package/src/i18n/locales/settings/en.po +16 -11
  48. package/src/i18n/locales/settings/en.ts +1 -1
  49. package/src/i18n/locales/settings/zh-Hans.po +17 -12
  50. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  51. package/src/i18n/locales/settings/zh-Hant.po +16 -11
  52. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  53. package/src/i18n/locales.ts +84 -2
  54. package/src/i18n/middleware.ts +25 -16
  55. package/src/i18n/supported-locales.ts +153 -0
  56. package/src/lib/__tests__/csp-builder.test.ts +19 -2
  57. package/src/lib/__tests__/feed.test.ts +242 -1
  58. package/src/lib/__tests__/post-meta.test.ts +0 -1
  59. package/src/lib/__tests__/view.test.ts +0 -1
  60. package/src/lib/csp-builder.ts +28 -10
  61. package/src/lib/feed.ts +153 -3
  62. package/src/middleware/__tests__/secure-headers.test.ts +89 -0
  63. package/src/middleware/auth.ts +1 -1
  64. package/src/middleware/secure-headers.ts +47 -1
  65. package/src/node/__tests__/cli-runtime-target.test.ts +110 -2
  66. package/src/node/__tests__/cli-site-snapshot.test.ts +308 -13
  67. package/src/node/__tests__/cli-site-token-env.test.ts +2 -7
  68. package/src/node/__tests__/cli-snapshot-meta.test.ts +85 -0
  69. package/src/node/__tests__/cli-sql-export.test.ts +49 -0
  70. package/src/node/index.ts +1 -0
  71. package/src/preset.css +8 -2
  72. package/src/routes/api/__tests__/settings.test.ts +3 -2
  73. package/src/routes/api/github-sync.tsx +1 -1
  74. package/src/routes/api/settings.ts +4 -1
  75. package/src/routes/auth/signin.tsx +6 -0
  76. package/src/routes/pages/archive.tsx +4 -2
  77. package/src/services/__tests__/post.test.ts +19 -19
  78. package/src/services/__tests__/search.test.ts +0 -1
  79. package/src/services/__tests__/settings.test.ts +22 -3
  80. package/src/services/bootstrap.ts +7 -3
  81. package/src/services/collection.ts +3 -3
  82. package/src/services/export.ts +0 -3
  83. package/src/services/navigation.ts +0 -2
  84. package/src/services/path.ts +1 -38
  85. package/src/services/post.ts +32 -66
  86. package/src/services/search.ts +0 -6
  87. package/src/services/settings.ts +47 -6
  88. package/src/services/site-admin.ts +6 -1
  89. package/src/styles/ui.css +12 -23
  90. package/src/types/entities.ts +0 -1
  91. package/src/ui/color-themes.ts +1 -1
  92. package/src/ui/dash/settings/GeneralContent.tsx +17 -19
  93. package/src/ui/dash/settings/SettingsRootContent.tsx +17 -28
  94. package/src/ui/feed/NoteCard.tsx +1 -11
  95. package/src/ui/feed/__tests__/timeline-cards.test.ts +1 -1
  96. package/src/ui/pages/PostPage.tsx +2 -0
  97. package/bin/commands/collections.js +0 -268
  98. package/bin/commands/media.js +0 -302
  99. package/bin/commands/posts.js +0 -262
  100. package/bin/commands/search.js +0 -53
  101. package/bin/commands/settings.js +0 -93
  102. package/bin/lib/http-api.js +0 -223
  103. package/bin/lib/media-upload.js +0 -206
  104. package/dist/app-CM7sb3xO.js +0 -5
  105. package/dist/client/_assets/client-DDs6NzB3.css +0 -2
  106. package/src/__tests__/bin/content-cli.test.ts +0 -179
  107. package/src/__tests__/bin/media-cli.test.ts +0 -192
  108. /package/dist/{github-api-BkRWnqMx.js → github-api-Bh0PH3zr.js} +0 -0
  109. /package/dist/{github-app-WeadXMb8.js → github-app-D0GvNnqp.js} +0 -0
@@ -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
+ }