@dinoxx/dinox-cli 1.0.18 → 1.0.19

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 (111) hide show
  1. package/README.md +72 -34
  2. package/dist/cli.js +15 -1
  3. package/dist/commands/auth/index.d.ts +2 -0
  4. package/dist/commands/auth/index.js +209 -163
  5. package/dist/commands/boxes/index.d.ts +2 -0
  6. package/dist/commands/boxes/index.js +119 -75
  7. package/dist/commands/boxes/repo.d.ts +8 -0
  8. package/dist/commands/boxes/repo.js +41 -0
  9. package/dist/commands/config/index.d.ts +2 -0
  10. package/dist/commands/config/index.js +85 -36
  11. package/dist/commands/descriptor/register.d.ts +8 -0
  12. package/dist/commands/descriptor/register.js +113 -0
  13. package/dist/commands/descriptor/types.d.ts +37 -0
  14. package/dist/commands/descriptor/types.js +17 -0
  15. package/dist/commands/graph/index.d.ts +2 -0
  16. package/dist/commands/graph/index.js +143 -73
  17. package/dist/commands/info/index.d.ts +2 -0
  18. package/dist/commands/info/index.js +41 -14
  19. package/dist/commands/mcp/index.js +11 -10
  20. package/dist/commands/mcp/server.js +6 -3
  21. package/dist/commands/mcp/tools.js +30 -104
  22. package/dist/commands/mcp/types.d.ts +8 -1
  23. package/dist/commands/meta/index.d.ts +2 -0
  24. package/dist/commands/meta/index.js +64 -33
  25. package/dist/commands/meta/repo.d.ts +6 -0
  26. package/dist/commands/meta/repo.js +6 -0
  27. package/dist/commands/notes/actions.d.ts +10 -0
  28. package/dist/commands/notes/actions.js +403 -0
  29. package/dist/commands/notes/descriptors.d.ts +4 -0
  30. package/dist/commands/notes/descriptors.js +250 -0
  31. package/dist/commands/notes/dryRun.d.ts +31 -0
  32. package/dist/commands/notes/dryRun.js +130 -0
  33. package/dist/commands/notes/index.d.ts +2 -51
  34. package/dist/commands/notes/index.js +2 -581
  35. package/dist/commands/notes/repo.d.ts +9 -0
  36. package/dist/commands/notes/repo.js +56 -10
  37. package/dist/commands/notes/runtime.d.ts +28 -0
  38. package/dist/commands/notes/runtime.js +185 -0
  39. package/dist/commands/notes/searchOutput.d.ts +10 -2
  40. package/dist/commands/notes/searchOutput.js +14 -2
  41. package/dist/commands/notes/searchResponse.d.ts +51 -0
  42. package/dist/commands/notes/searchResponse.js +56 -0
  43. package/dist/commands/notes/searchSql.js +2 -0
  44. package/dist/commands/prompt/index.d.ts +2 -0
  45. package/dist/commands/prompt/index.js +122 -72
  46. package/dist/commands/prompt/repo.d.ts +7 -0
  47. package/dist/commands/prompt/repo.js +46 -0
  48. package/dist/commands/schema/commandMetadata.d.ts +2 -0
  49. package/dist/commands/schema/commandMetadata.js +18 -0
  50. package/dist/commands/schema/globalOptions.d.ts +3 -0
  51. package/dist/commands/schema/globalOptions.js +3 -0
  52. package/dist/commands/schema/index.d.ts +4 -0
  53. package/dist/commands/schema/index.js +109 -0
  54. package/dist/commands/schema/renderSkills.d.ts +6 -0
  55. package/dist/commands/schema/renderSkills.js +216 -0
  56. package/dist/commands/schema/spec.d.ts +6 -0
  57. package/dist/commands/schema/spec.js +40 -0
  58. package/dist/commands/schema/types.d.ts +47 -0
  59. package/dist/commands/schema/types.js +1 -0
  60. package/dist/commands/storage/index.d.ts +4 -0
  61. package/dist/commands/storage/index.js +292 -0
  62. package/dist/commands/storage/repo.d.ts +162 -0
  63. package/dist/commands/storage/repo.js +751 -0
  64. package/dist/commands/sync.d.ts +2 -0
  65. package/dist/commands/sync.js +41 -27
  66. package/dist/commands/tags/index.d.ts +2 -0
  67. package/dist/commands/tags/index.js +121 -79
  68. package/dist/commands/tags/repo.d.ts +7 -0
  69. package/dist/commands/tags/repo.js +52 -0
  70. package/dist/commands/todo/dryRun.d.ts +23 -0
  71. package/dist/commands/todo/dryRun.js +133 -0
  72. package/dist/commands/todo/index.d.ts +2 -0
  73. package/dist/commands/todo/index.js +285 -135
  74. package/dist/commands/todo/repo.d.ts +11 -0
  75. package/dist/commands/todo/repo.js +37 -9
  76. package/dist/commands/update.d.ts +3 -0
  77. package/dist/commands/update.js +35 -13
  78. package/dist/commands/writeDryRun.d.ts +62 -0
  79. package/dist/commands/writeDryRun.js +131 -0
  80. package/dist/constants/links.d.ts +1 -1
  81. package/dist/constants/links.js +1 -1
  82. package/dist/daemon/ensure.js +3 -2
  83. package/dist/daemon/index.js +17 -15
  84. package/dist/dinox.js +9 -13
  85. package/dist/powersync/schema/index.d.ts +27 -0
  86. package/dist/powersync/schema/index.js +2 -0
  87. package/dist/powersync/schema/note.d.ts +1 -0
  88. package/dist/powersync/schema/note.js +1 -0
  89. package/dist/powersync/schema/projects.d.ts +3 -0
  90. package/dist/powersync/schema/projects.js +3 -0
  91. package/dist/powersync/schema/storage.d.ts +24 -0
  92. package/dist/powersync/schema/storage.js +24 -0
  93. package/dist/skills/check.d.ts +6 -0
  94. package/dist/skills/check.js +254 -0
  95. package/dist/utils/bidlinkCanonicalization.d.ts +2 -0
  96. package/dist/utils/bidlinkCanonicalization.js +161 -0
  97. package/dist/utils/dryRun.d.ts +13 -0
  98. package/dist/utils/dryRun.js +69 -0
  99. package/dist/utils/output.d.ts +19 -0
  100. package/dist/utils/output.js +155 -1
  101. package/dist/utils/terminalSanitization.d.ts +2 -0
  102. package/dist/utils/terminalSanitization.js +33 -0
  103. package/dist/utils/tiptapMarkdown.d.ts +4 -1
  104. package/dist/utils/tiptapMarkdown.js +285 -113
  105. package/dist/utils/tiptapMarkdownSchema.d.ts +37 -0
  106. package/dist/utils/tiptapMarkdownSchema.js +577 -0
  107. package/dist/utils/tiptapMarkdownSchemaUtils.d.ts +74 -0
  108. package/dist/utils/tiptapMarkdownSchemaUtils.js +394 -0
  109. package/dist/utils/updateNotice.d.ts +7 -0
  110. package/dist/utils/updateNotice.js +64 -0
  111. package/package.json +25 -3
@@ -0,0 +1,751 @@
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
5
+ import sharp from 'sharp';
6
+ import { generateId } from '../../utils/id.js';
7
+ import { DinoxError } from '../../utils/errors.js';
8
+ const ACTIVE_CUSTOM_STORAGE_CONFIG_ID_KEY = 'storage:active_custom_config_id';
9
+ const DEFAULT_STORAGE_PROTOCOL = 's3';
10
+ const DEFAULT_STORAGE_PATH_PREFIX = 'dinox_note/';
11
+ const DEFAULT_CONTENT_TYPE = 'application/octet-stream';
12
+ const OFFICIAL_STORAGE_PROVIDER = 'aliyun-official';
13
+ const OFFICIAL_STORAGE_PROVIDER_NAME = '官方云';
14
+ const UPLOAD_CATEGORY_VALUES = ['images', 'audios', 'files', 'videos'];
15
+ function normalizeText(value) {
16
+ return typeof value === 'string' ? value.trim() : '';
17
+ }
18
+ function normalizeTextArray(raw) {
19
+ const input = normalizeText(raw);
20
+ if (!input) {
21
+ return [];
22
+ }
23
+ try {
24
+ const parsed = JSON.parse(input);
25
+ if (!Array.isArray(parsed)) {
26
+ return [];
27
+ }
28
+ return parsed
29
+ .map((entry) => normalizeText(String(entry)))
30
+ .filter(Boolean);
31
+ }
32
+ catch {
33
+ return [];
34
+ }
35
+ }
36
+ function normalizeBool(value) {
37
+ return Number(value ?? 0) === 1;
38
+ }
39
+ function normalizeNumber(value) {
40
+ const numeric = typeof value === 'number' ? value : Number(value ?? 0);
41
+ return Number.isFinite(numeric) ? numeric : 0;
42
+ }
43
+ function normalizeStoragePathPrefix(value) {
44
+ const trimmed = value.trim();
45
+ if (!trimmed) {
46
+ return DEFAULT_STORAGE_PATH_PREFIX;
47
+ }
48
+ return trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
49
+ }
50
+ function finishStorageHost(value) {
51
+ const trimmed = value.trim();
52
+ if (!trimmed) {
53
+ throw new DinoxError('Storage endpoint is required.');
54
+ }
55
+ const candidate = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
56
+ try {
57
+ const parsed = new URL(candidate);
58
+ parsed.pathname = '';
59
+ parsed.search = '';
60
+ parsed.hash = '';
61
+ return parsed.toString().replace(/\/$/, '');
62
+ }
63
+ catch (error) {
64
+ throw new DinoxError(`Invalid storage endpoint: ${trimmed}`, { cause: error });
65
+ }
66
+ }
67
+ function buildVirtualHostedStyleUrl(bucket, endpoint, key) {
68
+ const parsed = new URL(endpoint);
69
+ const protocol = parsed.protocol || 'https:';
70
+ const port = parsed.port ? `:${parsed.port}` : '';
71
+ const host = parsed.hostname || parsed.host;
72
+ const hostWithBucket = host.startsWith(`${bucket}.`) ? host : `${bucket}.${host}`;
73
+ return {
74
+ finalUrl: `${protocol}//${hostWithBucket}${port}/${key}`,
75
+ domain: `${protocol}//${hostWithBucket}${port}`,
76
+ };
77
+ }
78
+ function buildPathStyleUrl(bucket, endpoint, key) {
79
+ const parsed = new URL(endpoint);
80
+ const protocol = parsed.protocol || 'https:';
81
+ const port = parsed.port ? `:${parsed.port}` : '';
82
+ const host = parsed.hostname || parsed.host;
83
+ return {
84
+ finalUrl: `${protocol}//${host}${port}/${bucket}/${key}`,
85
+ domain: `${protocol}//${host}${port}`,
86
+ };
87
+ }
88
+ function getFileExtension(filePath) {
89
+ const extension = path.extname(filePath).trim().replace(/^\./, '');
90
+ return extension.toLowerCase();
91
+ }
92
+ function guessContentType(filePath) {
93
+ const extension = getFileExtension(filePath);
94
+ switch (extension) {
95
+ case 'jpg':
96
+ case 'jpeg':
97
+ return 'image/jpeg';
98
+ case 'png':
99
+ return 'image/png';
100
+ case 'gif':
101
+ return 'image/gif';
102
+ case 'webp':
103
+ return 'image/webp';
104
+ case 'svg':
105
+ return 'image/svg+xml';
106
+ case 'mp3':
107
+ return 'audio/mpeg';
108
+ case 'm4a':
109
+ return 'audio/mp4';
110
+ case 'wav':
111
+ return 'audio/wav';
112
+ case 'ogg':
113
+ case 'opus':
114
+ return 'audio/ogg';
115
+ case 'mp4':
116
+ return 'video/mp4';
117
+ case 'mov':
118
+ return 'video/quicktime';
119
+ case 'webm':
120
+ return 'video/webm';
121
+ case 'pdf':
122
+ return 'application/pdf';
123
+ case 'json':
124
+ return 'application/json';
125
+ case 'md':
126
+ return 'text/markdown';
127
+ case 'txt':
128
+ return 'text/plain';
129
+ case 'csv':
130
+ return 'text/csv';
131
+ default:
132
+ return DEFAULT_CONTENT_TYPE;
133
+ }
134
+ }
135
+ function buildObjectId(userId, category, filePath) {
136
+ const ext = getFileExtension(filePath);
137
+ const suffix = ext ? `.${ext}` : '';
138
+ return `assets/${userId}/${category}/${generateId()}${suffix}`;
139
+ }
140
+ function generateThumbnailKey(originalKey) {
141
+ const lastSlashIndex = originalKey.lastIndexOf('/');
142
+ if (lastSlashIndex === -1) {
143
+ const filenameWithoutExt = originalKey.replace(/\.[^.]+$/, '');
144
+ return `thumb/${filenameWithoutExt}_thumbnail.webp`;
145
+ }
146
+ const dirPath = originalKey.slice(0, lastSlashIndex);
147
+ const filename = originalKey.slice(lastSlashIndex + 1);
148
+ const filenameWithoutExt = filename.replace(/\.[^.]+$/, '');
149
+ return `${dirPath}/thumb/${filenameWithoutExt}_thumbnail.webp`;
150
+ }
151
+ function buildStorageTarget(config, key) {
152
+ const resolvedEndpoint = finishStorageHost(config.endpoint);
153
+ if (config.customDomain) {
154
+ const domain = finishStorageHost(config.customDomain);
155
+ return {
156
+ storageUrl: `${domain}/${key}`,
157
+ domain,
158
+ };
159
+ }
160
+ const target = config.forcePathStyle
161
+ ? buildPathStyleUrl(config.bucket, resolvedEndpoint, key)
162
+ : buildVirtualHostedStyleUrl(config.bucket, resolvedEndpoint, key);
163
+ return {
164
+ storageUrl: target.finalUrl,
165
+ domain: target.domain,
166
+ };
167
+ }
168
+ function isOfficialStorageProvider(provider) {
169
+ return provider === OFFICIAL_STORAGE_PROVIDER;
170
+ }
171
+ function getStorageProviderName(provider) {
172
+ if (isOfficialStorageProvider(provider)) {
173
+ return OFFICIAL_STORAGE_PROVIDER_NAME;
174
+ }
175
+ switch (provider) {
176
+ case 'bitiful':
177
+ return '缤纷云';
178
+ case 'tencent':
179
+ return '腾讯云';
180
+ case 'aliyun':
181
+ return '阿里云';
182
+ case 'aws':
183
+ return 'AWS';
184
+ case 'qiniu':
185
+ return '七牛云';
186
+ case 'cloudflare':
187
+ return 'Cloudflare';
188
+ default:
189
+ return provider;
190
+ }
191
+ }
192
+ function toSanitizedStorage(config, isActive) {
193
+ return {
194
+ id: config.id,
195
+ aliasName: config.aliasName,
196
+ provider: config.provider,
197
+ endpoint: config.endpoint,
198
+ bucket: config.bucket,
199
+ region: config.region,
200
+ forcePathStyle: config.forcePathStyle,
201
+ pathPrefix: config.pathPrefix,
202
+ customDomain: config.customDomain,
203
+ legacyDomains: config.legacyDomains,
204
+ isPrivate: config.isPrivate,
205
+ hasAccessKeyId: config.accessKeyId.length > 0,
206
+ hasSecretAccessKey: config.secretAccessKey.length > 0,
207
+ isActive,
208
+ };
209
+ }
210
+ function buildStorageUsageLabel(args) {
211
+ const { provider, bucket, configsByKey, configsByProvider } = args;
212
+ if (isOfficialStorageProvider(provider)) {
213
+ return {
214
+ label: OFFICIAL_STORAGE_PROVIDER_NAME,
215
+ storageConfigId: null,
216
+ aliasName: OFFICIAL_STORAGE_PROVIDER_NAME,
217
+ isOfficial: true,
218
+ };
219
+ }
220
+ const exactConfig = configsByKey.get(`${provider}::${bucket}`);
221
+ if (exactConfig?.aliasName) {
222
+ return {
223
+ label: exactConfig.aliasName,
224
+ storageConfigId: exactConfig.id,
225
+ aliasName: exactConfig.aliasName,
226
+ isOfficial: false,
227
+ };
228
+ }
229
+ const sameProviderConfigs = configsByProvider.get(provider) ?? [];
230
+ if (sameProviderConfigs.length === 1 && sameProviderConfigs[0]?.aliasName) {
231
+ return {
232
+ label: sameProviderConfigs[0].aliasName,
233
+ storageConfigId: sameProviderConfigs[0].id,
234
+ aliasName: sameProviderConfigs[0].aliasName,
235
+ isOfficial: false,
236
+ };
237
+ }
238
+ return {
239
+ label: bucket ? `${getStorageProviderName(provider)} · ${bucket}` : getStorageProviderName(provider),
240
+ storageConfigId: exactConfig?.id ?? null,
241
+ aliasName: exactConfig?.aliasName ?? null,
242
+ isOfficial: false,
243
+ };
244
+ }
245
+ async function getTableColumns(db, tableName) {
246
+ const rows = await db.getAll(`PRAGMA table_info(${tableName})`, []);
247
+ return new Set(rows.map((row) => row.name).filter(Boolean));
248
+ }
249
+ async function listStoredCustomStorageConfigs(db, userId) {
250
+ const rows = await db.getAll(`
251
+ SELECT
252
+ TRIM(COALESCE(id, '')) AS id,
253
+ TRIM(COALESCE(alias_name, '')) AS alias_name,
254
+ TRIM(COALESCE(protocol, '')) AS protocol,
255
+ TRIM(COALESCE(provider, '')) AS provider,
256
+ TRIM(COALESCE(endpoint, '')) AS endpoint,
257
+ TRIM(COALESCE(bucket, '')) AS bucket,
258
+ TRIM(COALESCE(region, '')) AS region,
259
+ TRIM(COALESCE(access_key_id, '')) AS access_key_id,
260
+ TRIM(COALESCE(secret_access_key, '')) AS secret_access_key,
261
+ force_path_style,
262
+ TRIM(COALESCE(path_prefix, '')) AS path_prefix,
263
+ TRIM(COALESCE(custom_domain, '')) AS custom_domain,
264
+ legacy_domains,
265
+ is_private,
266
+ updated_at,
267
+ deleted_at
268
+ FROM c_storage
269
+ WHERE TRIM(COALESCE(user_id, '')) = ?
270
+ AND TRIM(COALESCE(protocol, '')) = ?
271
+ AND deleted_at IS NULL
272
+ AND TRIM(COALESCE(id, '')) <> ''
273
+ ORDER BY COALESCE(sort_rank, 2147483647) ASC, updated_at DESC, id ASC
274
+ `, [userId, DEFAULT_STORAGE_PROTOCOL]);
275
+ return rows
276
+ .map((row) => ({
277
+ id: normalizeText(row.id),
278
+ aliasName: normalizeText(row.alias_name),
279
+ provider: normalizeText(row.provider),
280
+ endpoint: normalizeText(row.endpoint),
281
+ bucket: normalizeText(row.bucket),
282
+ region: normalizeText(row.region),
283
+ accessKeyId: normalizeText(row.access_key_id),
284
+ secretAccessKey: normalizeText(row.secret_access_key),
285
+ forcePathStyle: normalizeBool(row.force_path_style),
286
+ pathPrefix: normalizeStoragePathPrefix(normalizeText(row.path_prefix)),
287
+ customDomain: normalizeText(row.custom_domain),
288
+ legacyDomains: normalizeTextArray(row.legacy_domains),
289
+ isPrivate: normalizeBool(row.is_private),
290
+ }))
291
+ .filter((row) => row.id.length > 0);
292
+ }
293
+ async function getActiveCustomStorageConfigId(db, userId) {
294
+ const rows = await db.getAll(`
295
+ SELECT c_value
296
+ FROM c_config_property
297
+ WHERE TRIM(COALESCE(c_key, '')) = ?
298
+ AND TRIM(COALESCE(user_id, '')) = ?
299
+ AND (is_del IS NULL OR is_del = 0)
300
+ ORDER BY updated_at DESC, created_at DESC, id DESC
301
+ LIMIT 1
302
+ `, [ACTIVE_CUSTOM_STORAGE_CONFIG_ID_KEY, userId]);
303
+ return normalizeText(rows[0]?.c_value ?? null) || null;
304
+ }
305
+ function resolveStorageSelection(args) {
306
+ const explicitId = normalizeText(args.storageId);
307
+ if (explicitId) {
308
+ const config = args.configs.find((item) => item.id === explicitId);
309
+ if (!config) {
310
+ throw new DinoxError(`Storage config not found: ${explicitId}`, {
311
+ suggestion: 'Run `dino storage list --format json` to inspect available storage ids.',
312
+ });
313
+ }
314
+ return { config, selectionSource: 'storage_id' };
315
+ }
316
+ const activeId = normalizeText(args.activeCustomStorageConfigId);
317
+ if (activeId) {
318
+ const config = args.configs.find((item) => item.id === activeId);
319
+ if (config) {
320
+ return { config, selectionSource: 'active' };
321
+ }
322
+ }
323
+ if (args.configs.length === 1) {
324
+ return { config: args.configs[0], selectionSource: 'single_available_config' };
325
+ }
326
+ throw new DinoxError('No active custom storage config is available for upload.', {
327
+ suggestion: 'Set an active custom storage config in the app, or pass `--storage-id <id>`.',
328
+ });
329
+ }
330
+ function buildStorageUploadPlan(args) {
331
+ const storageKey = normalizeText(args.key) || `${args.config.pathPrefix}${buildObjectId(args.userId, args.category, args.filePath)}`;
332
+ const target = buildStorageTarget(args.config, storageKey);
333
+ return {
334
+ storage: toSanitizedStorage({
335
+ ...args.config,
336
+ endpoint: finishStorageHost(args.config.endpoint),
337
+ }, args.selectionSource === 'active'),
338
+ selectionSource: args.selectionSource,
339
+ filePath: args.filePath,
340
+ fileName: path.basename(args.filePath),
341
+ sizeBytes: args.sizeBytes,
342
+ category: args.category,
343
+ contentType: guessContentType(args.filePath),
344
+ storageKey,
345
+ storageUrl: target.storageUrl,
346
+ domain: target.domain,
347
+ };
348
+ }
349
+ function buildStorageTestPlan(args) {
350
+ const storageKey = `${args.config.pathPrefix}dinox-cli-test/${args.userId}/${generateId()}.txt`;
351
+ const target = buildStorageTarget(args.config, storageKey);
352
+ const payload = Buffer.from(`dinox-cli storage test\nprovider=${args.config.provider}\nuser=${args.userId}\ncreated_at=${new Date().toISOString()}\n`, 'utf8');
353
+ return {
354
+ storage: toSanitizedStorage({
355
+ ...args.config,
356
+ endpoint: finishStorageHost(args.config.endpoint),
357
+ }, args.selectionSource === 'active'),
358
+ selectionSource: args.selectionSource,
359
+ contentType: 'text/plain',
360
+ sizeBytes: payload.byteLength,
361
+ storageKey,
362
+ storageUrl: target.storageUrl,
363
+ domain: target.domain,
364
+ };
365
+ }
366
+ async function defaultUploadObject(input) {
367
+ const client = new S3Client({
368
+ endpoint: finishStorageHost(input.storage.endpoint),
369
+ region: input.storage.region,
370
+ forcePathStyle: input.storage.forcePathStyle,
371
+ credentials: {
372
+ accessKeyId: input.storage.accessKeyId,
373
+ secretAccessKey: input.storage.secretAccessKey,
374
+ },
375
+ });
376
+ const response = await client.send(new PutObjectCommand({
377
+ Bucket: input.bucket,
378
+ Key: input.key,
379
+ Body: fs.createReadStream(input.localPath),
380
+ ContentType: input.contentType,
381
+ ContentLength: input.contentLength,
382
+ }));
383
+ return {
384
+ etag: normalizeText(response.ETag) || null,
385
+ };
386
+ }
387
+ async function defaultDeleteObject(input) {
388
+ const client = new S3Client({
389
+ endpoint: finishStorageHost(input.storage.endpoint),
390
+ region: input.storage.region,
391
+ forcePathStyle: input.storage.forcePathStyle,
392
+ credentials: {
393
+ accessKeyId: input.storage.accessKeyId,
394
+ secretAccessKey: input.storage.secretAccessKey,
395
+ },
396
+ });
397
+ await client.send(new DeleteObjectCommand({
398
+ Bucket: input.bucket,
399
+ Key: input.key,
400
+ }));
401
+ }
402
+ async function defaultUploadBytes(input) {
403
+ const client = new S3Client({
404
+ endpoint: finishStorageHost(input.storage.endpoint),
405
+ region: input.storage.region,
406
+ forcePathStyle: input.storage.forcePathStyle,
407
+ credentials: {
408
+ accessKeyId: input.storage.accessKeyId,
409
+ secretAccessKey: input.storage.secretAccessKey,
410
+ },
411
+ });
412
+ const response = await client.send(new PutObjectCommand({
413
+ Bucket: input.bucket,
414
+ Key: input.key,
415
+ Body: input.body,
416
+ ContentType: input.contentType,
417
+ ContentLength: input.body.byteLength,
418
+ }));
419
+ return {
420
+ etag: normalizeText(response.ETag) || null,
421
+ };
422
+ }
423
+ async function generateThumbnailBuffer(localPath) {
424
+ return sharp(localPath)
425
+ .resize({ width: 400, withoutEnlargement: true })
426
+ .webp({ quality: 50 })
427
+ .toBuffer();
428
+ }
429
+ async function insertUploadedResource(db, args) {
430
+ const columns = await getTableColumns(db, 'c_resource');
431
+ const requiredColumns = [
432
+ 'id',
433
+ 'user_id',
434
+ 'name',
435
+ 'mime_type',
436
+ 'size_bytes',
437
+ 'storage_key',
438
+ 'storage_url',
439
+ 'storage_provider',
440
+ 'bucket',
441
+ 'created_at',
442
+ 'updated_at',
443
+ 'is_del',
444
+ ];
445
+ const missingRequired = requiredColumns.filter((column) => !columns.has(column));
446
+ if (missingRequired.length > 0) {
447
+ throw new DinoxError(`c_resource table is missing required columns: ${missingRequired.join(', ')}`);
448
+ }
449
+ const id = generateId();
450
+ const now = new Date().toISOString();
451
+ const insertEntries = [];
452
+ const pushInsert = (column, value) => {
453
+ if (columns.has(column)) {
454
+ insertEntries.push([column, value]);
455
+ }
456
+ };
457
+ pushInsert('id', id);
458
+ pushInsert('user_id', args.userId);
459
+ pushInsert('name', args.plan.fileName);
460
+ pushInsert('mime_type', args.plan.contentType);
461
+ pushInsert('size_bytes', args.plan.sizeBytes);
462
+ pushInsert('storage_config_id', args.storage.id);
463
+ pushInsert('storage_key', args.plan.storageKey);
464
+ pushInsert('storage_url', args.plan.storageUrl);
465
+ pushInsert('storage_provider', args.storage.provider);
466
+ pushInsert('bucket', args.storage.bucket);
467
+ pushInsert('is_public', args.storage.isPrivate ? 0 : 1);
468
+ pushInsert('created_at', now);
469
+ pushInsert('updated_at', now);
470
+ pushInsert('is_del', 0);
471
+ pushInsert('status', 'uploaded');
472
+ pushInsert('uploaded_at', now);
473
+ pushInsert('etag', args.etag);
474
+ pushInsert('checksum', args.thumbnailUrl);
475
+ pushInsert('category', args.plan.category);
476
+ pushInsert('endpoint', args.storage.endpoint);
477
+ pushInsert('domain', args.plan.domain);
478
+ pushInsert('extra_data', '{}');
479
+ pushInsert('deleted_at', null);
480
+ pushInsert('content_sha256', null);
481
+ pushInsert('content', null);
482
+ pushInsert('content_md', null);
483
+ pushInsert('summary', null);
484
+ const insertColumns = insertEntries.map(([column]) => column);
485
+ const placeholders = insertColumns.map(() => '?').join(', ');
486
+ const params = insertEntries.map(([, value]) => value);
487
+ await db.execute(`
488
+ INSERT INTO c_resource (${insertColumns.join(', ')})
489
+ VALUES (${placeholders})
490
+ `, params);
491
+ return id;
492
+ }
493
+ export function parseStorageUploadCategory(value) {
494
+ const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
495
+ if (UPLOAD_CATEGORY_VALUES.includes(normalized)) {
496
+ return normalized;
497
+ }
498
+ throw new DinoxError(`--category must be one of: ${UPLOAD_CATEGORY_VALUES.join(', ')}`);
499
+ }
500
+ export async function listStorageConfigs(db, userId) {
501
+ const activeCustomStorageConfigId = await getActiveCustomStorageConfigId(db, userId);
502
+ const configs = await listStoredCustomStorageConfigs(db, userId);
503
+ return {
504
+ activeCustomStorageConfigId,
505
+ usesOfficialStorage: !activeCustomStorageConfigId,
506
+ items: configs.map((config) => toSanitizedStorage(config, config.id === activeCustomStorageConfigId)),
507
+ };
508
+ }
509
+ export async function getStorageStats(db, userId) {
510
+ const rows = await db.getAll(`
511
+ SELECT
512
+ TRIM(COALESCE(storage_provider, '')) AS provider,
513
+ TRIM(COALESCE(bucket, '')) AS bucket,
514
+ COALESCE(SUM(COALESCE(size_bytes, 0)), 0) AS total_bytes,
515
+ COUNT(1) AS file_count
516
+ FROM c_resource
517
+ WHERE (is_del IS NULL OR is_del = 0)
518
+ AND TRIM(COALESCE(storage_provider, '')) <> ''
519
+ AND TRIM(COALESCE(storage_provider, '')) <> 'local'
520
+ AND TRIM(COALESCE(status, '')) = 'uploaded'
521
+ GROUP BY storage_provider, bucket
522
+ ORDER BY total_bytes DESC, provider ASC, bucket ASC
523
+ `, []);
524
+ const configs = await listStoredCustomStorageConfigs(db, userId);
525
+ const configsByKey = new Map();
526
+ const configsByProvider = new Map();
527
+ for (const config of configs) {
528
+ configsByKey.set(`${config.provider}::${config.bucket}`, config);
529
+ const current = configsByProvider.get(config.provider);
530
+ if (current) {
531
+ current.push(config);
532
+ }
533
+ else {
534
+ configsByProvider.set(config.provider, [config]);
535
+ }
536
+ }
537
+ const entries = rows
538
+ .map((row) => {
539
+ const provider = normalizeText(row.provider);
540
+ const bucket = normalizeText(row.bucket);
541
+ const labelInfo = buildStorageUsageLabel({
542
+ provider,
543
+ bucket,
544
+ configsByKey,
545
+ configsByProvider,
546
+ });
547
+ return {
548
+ provider,
549
+ bucket,
550
+ label: labelInfo.label,
551
+ totalBytes: normalizeNumber(row.total_bytes),
552
+ fileCount: normalizeNumber(row.file_count),
553
+ storageConfigId: labelInfo.storageConfigId,
554
+ aliasName: labelInfo.aliasName,
555
+ isOfficial: labelInfo.isOfficial,
556
+ };
557
+ })
558
+ .filter((entry) => entry.provider.length > 0 && entry.fileCount > 0)
559
+ .sort((left, right) => {
560
+ if (left.isOfficial !== right.isOfficial) {
561
+ return left.isOfficial ? -1 : 1;
562
+ }
563
+ return right.totalBytes - left.totalBytes;
564
+ });
565
+ return {
566
+ totalBytes: entries.reduce((sum, entry) => sum + entry.totalBytes, 0),
567
+ totalFiles: entries.reduce((sum, entry) => sum + entry.fileCount, 0),
568
+ entries,
569
+ };
570
+ }
571
+ export async function inspectStorageUpload(db, input, deps) {
572
+ const statFile = deps?.statFile ?? fsp.stat;
573
+ const filePath = input.filePath.trim();
574
+ if (!filePath) {
575
+ throw new DinoxError('File path is required.');
576
+ }
577
+ const stats = await statFile(filePath).catch((error) => {
578
+ throw new DinoxError(`File not found: ${filePath}`, { cause: error });
579
+ });
580
+ if (!stats.isFile()) {
581
+ throw new DinoxError(`Not a regular file: ${filePath}`);
582
+ }
583
+ const configs = await listStoredCustomStorageConfigs(db, input.userId);
584
+ if (configs.length === 0) {
585
+ throw new DinoxError('No custom storage configs are available.', {
586
+ suggestion: 'Configure a custom storage target in the app first.',
587
+ });
588
+ }
589
+ const activeCustomStorageConfigId = await getActiveCustomStorageConfigId(db, input.userId);
590
+ const { config, selectionSource } = resolveStorageSelection({
591
+ configs,
592
+ storageId: input.storageId,
593
+ activeCustomStorageConfigId,
594
+ });
595
+ if (!config.endpoint || !config.bucket || !config.region || !config.accessKeyId || !config.secretAccessKey) {
596
+ throw new DinoxError(`Storage config "${config.aliasName || config.id}" is incomplete for upload.`, {
597
+ suggestion: 'Fill in endpoint, region, bucket, access key id, and secret access key in the app.',
598
+ });
599
+ }
600
+ return buildStorageUploadPlan({
601
+ config,
602
+ selectionSource,
603
+ userId: input.userId,
604
+ filePath,
605
+ category: input.category,
606
+ key: input.key,
607
+ sizeBytes: stats.size,
608
+ });
609
+ }
610
+ export async function inspectStorageTest(db, input) {
611
+ const configs = await listStoredCustomStorageConfigs(db, input.userId);
612
+ if (configs.length === 0) {
613
+ throw new DinoxError('No custom storage configs are available.', {
614
+ suggestion: 'Configure a custom storage target in the app first.',
615
+ });
616
+ }
617
+ const activeCustomStorageConfigId = await getActiveCustomStorageConfigId(db, input.userId);
618
+ const { config, selectionSource } = resolveStorageSelection({
619
+ configs,
620
+ storageId: input.storageId,
621
+ activeCustomStorageConfigId,
622
+ });
623
+ if (!config.endpoint || !config.bucket || !config.region || !config.accessKeyId || !config.secretAccessKey) {
624
+ throw new DinoxError(`Storage config "${config.aliasName || config.id}" is incomplete for upload.`, {
625
+ suggestion: 'Fill in endpoint, region, bucket, access key id, and secret access key in the app.',
626
+ });
627
+ }
628
+ return buildStorageTestPlan({
629
+ config,
630
+ selectionSource,
631
+ userId: input.userId,
632
+ });
633
+ }
634
+ export async function testStorageUpload(db, input, deps) {
635
+ const plan = await inspectStorageTest(db, input);
636
+ const configs = await listStoredCustomStorageConfigs(db, input.userId);
637
+ const storage = configs.find((item) => item.id === plan.storage.id);
638
+ if (!storage) {
639
+ throw new DinoxError(`Storage config not found: ${plan.storage.id}`);
640
+ }
641
+ const uploadBytes = deps?.uploadBytes ?? defaultUploadBytes;
642
+ const payload = Buffer.from(`dinox-cli storage test\nprovider=${storage.provider}\nuser=${input.userId}\ncreated_at=${new Date().toISOString()}\n`, 'utf8');
643
+ await uploadBytes({
644
+ body: payload,
645
+ bucket: storage.bucket,
646
+ key: plan.storageKey,
647
+ contentType: plan.contentType,
648
+ storage,
649
+ });
650
+ return {
651
+ storageConfigId: storage.id,
652
+ aliasName: storage.aliasName,
653
+ provider: storage.provider,
654
+ bucket: storage.bucket,
655
+ endpoint: storage.endpoint,
656
+ domain: plan.domain,
657
+ storageKey: plan.storageKey,
658
+ storageUrl: plan.storageUrl,
659
+ contentType: plan.contentType,
660
+ sizeBytes: plan.sizeBytes,
661
+ selectionSource: plan.selectionSource,
662
+ isPrivate: storage.isPrivate,
663
+ };
664
+ }
665
+ export async function uploadFileToStorage(db, input, deps) {
666
+ const plan = await inspectStorageUpload(db, input, { statFile: deps?.statFile });
667
+ const configs = await listStoredCustomStorageConfigs(db, input.userId);
668
+ const storage = configs.find((item) => item.id === plan.storage.id);
669
+ if (!storage) {
670
+ throw new DinoxError(`Storage config not found: ${plan.storage.id}`);
671
+ }
672
+ const uploadObject = deps?.uploadObject ?? defaultUploadObject;
673
+ const uploadBytes = deps?.uploadBytes ?? defaultUploadBytes;
674
+ const deleteObject = deps?.deleteObject ?? defaultDeleteObject;
675
+ const uploaded = await uploadObject({
676
+ localPath: plan.filePath,
677
+ bucket: storage.bucket,
678
+ key: plan.storageKey,
679
+ contentType: plan.contentType,
680
+ contentLength: plan.sizeBytes,
681
+ storage,
682
+ });
683
+ let thumbnailUrl = null;
684
+ let uploadedThumbnailKey = null;
685
+ try {
686
+ if (plan.contentType.startsWith('image/')) {
687
+ const thumbnailKey = generateThumbnailKey(plan.storageKey);
688
+ const thumbnailBuffer = await generateThumbnailBuffer(plan.filePath);
689
+ const thumbnailTarget = buildStorageTarget(storage, thumbnailKey);
690
+ await uploadBytes({
691
+ body: thumbnailBuffer,
692
+ bucket: storage.bucket,
693
+ key: thumbnailKey,
694
+ contentType: 'image/webp',
695
+ storage,
696
+ });
697
+ uploadedThumbnailKey = thumbnailKey;
698
+ thumbnailUrl = thumbnailTarget.storageUrl;
699
+ }
700
+ const resourceId = await insertUploadedResource(db, {
701
+ userId: input.userId,
702
+ storage,
703
+ plan,
704
+ etag: uploaded.etag,
705
+ thumbnailUrl,
706
+ });
707
+ return {
708
+ resourceId,
709
+ storageConfigId: storage.id,
710
+ aliasName: storage.aliasName,
711
+ provider: storage.provider,
712
+ bucket: storage.bucket,
713
+ endpoint: storage.endpoint,
714
+ domain: plan.domain,
715
+ storageKey: plan.storageKey,
716
+ storageUrl: plan.storageUrl,
717
+ fileName: plan.fileName,
718
+ sizeBytes: plan.sizeBytes,
719
+ contentType: plan.contentType,
720
+ category: plan.category,
721
+ etag: uploaded.etag,
722
+ thumbnailUrl,
723
+ isPrivate: storage.isPrivate,
724
+ selectionSource: plan.selectionSource,
725
+ };
726
+ }
727
+ catch (error) {
728
+ const cleanupErrors = [];
729
+ if (uploadedThumbnailKey) {
730
+ await deleteObject({
731
+ bucket: storage.bucket,
732
+ key: uploadedThumbnailKey,
733
+ storage,
734
+ }).catch((cleanupError) => {
735
+ cleanupErrors.push(`thumbnail cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
736
+ });
737
+ }
738
+ await deleteObject({
739
+ bucket: storage.bucket,
740
+ key: plan.storageKey,
741
+ storage,
742
+ }).catch((cleanupError) => {
743
+ cleanupErrors.push(`main object cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
744
+ });
745
+ throw new DinoxError('Storage upload failed while generating the required thumbnail.', {
746
+ cause: error,
747
+ code: 'STORAGE_THUMBNAIL_UPLOAD_FAILED',
748
+ ...(cleanupErrors.length > 0 ? { details: { cleanupErrors } } : {}),
749
+ });
750
+ }
751
+ }