@dinoxx/dinox-cli 2.0.0 → 2.1.1

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 (48) hide show
  1. package/dist/commands/notes/repo/read.d.ts +8 -0
  2. package/dist/commands/notes/repo/read.js +321 -0
  3. package/dist/commands/notes/repo/resolve.d.ts +8 -0
  4. package/dist/commands/notes/repo/resolve.js +187 -0
  5. package/dist/commands/notes/repo/search.d.ts +42 -0
  6. package/dist/commands/notes/repo/search.js +513 -0
  7. package/dist/commands/notes/repo/shared.d.ts +190 -0
  8. package/dist/commands/notes/repo/shared.js +102 -0
  9. package/dist/commands/notes/repo/write.d.ts +24 -0
  10. package/dist/commands/notes/repo/write.js +251 -0
  11. package/dist/commands/notes/repo.d.ts +7 -186
  12. package/dist/commands/notes/repo.js +7 -1360
  13. package/dist/commands/schema/commandMetadata.js +4 -4
  14. package/dist/commands/schema/index.d.ts +1 -0
  15. package/dist/commands/schema/index.js +23 -2
  16. package/dist/commands/schema/renderSkills.d.ts +4 -0
  17. package/dist/commands/schema/renderSkills.js +41 -2
  18. package/dist/commands/skills/index.d.ts +26 -0
  19. package/dist/commands/skills/index.js +115 -11
  20. package/dist/commands/storage/repo/configs.d.ts +30 -0
  21. package/dist/commands/storage/repo/configs.js +259 -0
  22. package/dist/commands/storage/repo/operations.d.ts +25 -0
  23. package/dist/commands/storage/repo/operations.js +337 -0
  24. package/dist/commands/storage/repo/remote.d.ts +32 -0
  25. package/dist/commands/storage/repo/remote.js +132 -0
  26. package/dist/commands/storage/repo/resources.d.ts +12 -0
  27. package/dist/commands/storage/repo/resources.js +134 -0
  28. package/dist/commands/storage/repo/shared.d.ts +181 -0
  29. package/dist/commands/storage/repo/shared.js +36 -0
  30. package/dist/commands/storage/repo/targets.d.ts +12 -0
  31. package/dist/commands/storage/repo/targets.js +178 -0
  32. package/dist/commands/storage/repo.d.ts +3 -166
  33. package/dist/commands/storage/repo.js +4 -1034
  34. package/dist/contract/snapshot.d.ts +30 -0
  35. package/dist/contract/snapshot.js +114 -0
  36. package/dist/daemon/client.js +2 -2
  37. package/dist/powersync/uploader.js +27 -2
  38. package/dist/skills/commandRefs.d.ts +21 -0
  39. package/dist/skills/commandRefs.js +132 -0
  40. package/dist/skills/generate.d.ts +13 -0
  41. package/dist/skills/generate.js +80 -0
  42. package/dist/skills/registry.d.ts +13 -0
  43. package/dist/skills/registry.js +58 -0
  44. package/dist/utils/errorCodes.d.ts +311 -0
  45. package/dist/utils/errorCodes.js +92 -0
  46. package/dist/utils/errors.d.ts +13 -2
  47. package/dist/utils/errors.js +11 -0
  48. package/package.json +11 -4
@@ -1,1034 +1,4 @@
1
- import fs from 'node:fs';
2
- import fsp from 'node:fs/promises';
3
- import path from 'node:path';
4
- import { withPowerSyncWriteTransaction } from '../../powersync/runtime.js';
5
- import { generateId } from '../../utils/id.js';
6
- import { DinoxError } from '../../utils/errors.js';
7
- const ACTIVE_CUSTOM_STORAGE_CONFIG_ID_KEY = 'storage:active_custom_config_id';
8
- const DEFAULT_STORAGE_PROTOCOL = 's3';
9
- const DEFAULT_STORAGE_PATH_PREFIX = 'dinox_note/';
10
- const DEFAULT_CONTENT_TYPE = 'application/octet-stream';
11
- const OFFICIAL_STORAGE_PROVIDER = 'aliyun-official';
12
- const OFFICIAL_STORAGE_PROVIDER_NAME = '官方云';
13
- const UPLOAD_CATEGORY_VALUES = ['images', 'audios', 'files', 'videos'];
14
- function normalizeText(value) {
15
- return typeof value === 'string' ? value.trim() : '';
16
- }
17
- function normalizeTextArray(raw) {
18
- const input = normalizeText(raw);
19
- if (!input) {
20
- return [];
21
- }
22
- try {
23
- const parsed = JSON.parse(input);
24
- if (!Array.isArray(parsed)) {
25
- return [];
26
- }
27
- return parsed
28
- .map((entry) => normalizeText(String(entry)))
29
- .filter(Boolean);
30
- }
31
- catch {
32
- return [];
33
- }
34
- }
35
- function normalizeBool(value) {
36
- return Number(value ?? 0) === 1;
37
- }
38
- function normalizeNumber(value) {
39
- const numeric = typeof value === 'number' ? value : Number(value ?? 0);
40
- return Number.isFinite(numeric) ? numeric : 0;
41
- }
42
- function normalizeStoragePathPrefix(value) {
43
- const trimmed = value.trim();
44
- if (!trimmed) {
45
- return DEFAULT_STORAGE_PATH_PREFIX;
46
- }
47
- return trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
48
- }
49
- function finishStorageHost(value) {
50
- const trimmed = value.trim();
51
- if (!trimmed) {
52
- throw new DinoxError('Storage endpoint is required.');
53
- }
54
- const candidate = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
55
- try {
56
- const parsed = new URL(candidate);
57
- parsed.pathname = '';
58
- parsed.search = '';
59
- parsed.hash = '';
60
- return parsed.toString().replace(/\/$/, '');
61
- }
62
- catch (error) {
63
- throw new DinoxError(`Invalid storage endpoint: ${trimmed}`, { cause: error });
64
- }
65
- }
66
- function encodeStorageKeyForUrl(key) {
67
- return key
68
- .split('/')
69
- .map((segment) => {
70
- return encodeURIComponent(segment).replace(/[!'()*]/g, (character) => {
71
- return `%${character.charCodeAt(0).toString(16).toUpperCase()}`;
72
- });
73
- })
74
- .join('/');
75
- }
76
- function assertStorageKeyUrlSafe(key) {
77
- const unsafeSegment = key.split('/').find((segment) => segment === '.' || segment === '..');
78
- if (!unsafeSegment) {
79
- return;
80
- }
81
- throw new DinoxError('Storage object key must not contain "." or ".." path segments.', {
82
- code: 'INVALID_STORAGE_KEY',
83
- suggestion: 'Remove dot-only path segments from --key or the configured storage path prefix.',
84
- details: { key },
85
- });
86
- }
87
- function buildVirtualHostedStyleUrl(bucket, endpoint, key) {
88
- const parsed = new URL(endpoint);
89
- const protocol = parsed.protocol || 'https:';
90
- const port = parsed.port ? `:${parsed.port}` : '';
91
- const host = parsed.hostname || parsed.host;
92
- const hostWithBucket = host.startsWith(`${bucket}.`) ? host : `${bucket}.${host}`;
93
- const encodedKey = encodeStorageKeyForUrl(key);
94
- return {
95
- finalUrl: `${protocol}//${hostWithBucket}${port}/${encodedKey}`,
96
- domain: `${protocol}//${hostWithBucket}${port}`,
97
- };
98
- }
99
- function buildPathStyleUrl(bucket, endpoint, key) {
100
- const parsed = new URL(endpoint);
101
- const protocol = parsed.protocol || 'https:';
102
- const port = parsed.port ? `:${parsed.port}` : '';
103
- const host = parsed.hostname || parsed.host;
104
- const encodedKey = encodeStorageKeyForUrl(key);
105
- return {
106
- finalUrl: `${protocol}//${host}${port}/${bucket}/${encodedKey}`,
107
- domain: `${protocol}//${host}${port}`,
108
- };
109
- }
110
- function getFileExtension(filePath) {
111
- const extension = path.extname(filePath).trim().replace(/^\./, '');
112
- return extension.toLowerCase();
113
- }
114
- function guessContentType(filePath) {
115
- const extension = getFileExtension(filePath);
116
- switch (extension) {
117
- case 'jpg':
118
- case 'jpeg':
119
- return 'image/jpeg';
120
- case 'png':
121
- return 'image/png';
122
- case 'gif':
123
- return 'image/gif';
124
- case 'webp':
125
- return 'image/webp';
126
- case 'svg':
127
- return 'image/svg+xml';
128
- case 'mp3':
129
- return 'audio/mpeg';
130
- case 'm4a':
131
- return 'audio/mp4';
132
- case 'wav':
133
- return 'audio/wav';
134
- case 'ogg':
135
- case 'opus':
136
- return 'audio/ogg';
137
- case 'mp4':
138
- return 'video/mp4';
139
- case 'mov':
140
- return 'video/quicktime';
141
- case 'webm':
142
- return 'video/webm';
143
- case 'pdf':
144
- return 'application/pdf';
145
- case 'json':
146
- return 'application/json';
147
- case 'md':
148
- return 'text/markdown';
149
- case 'txt':
150
- return 'text/plain';
151
- case 'csv':
152
- return 'text/csv';
153
- default:
154
- return DEFAULT_CONTENT_TYPE;
155
- }
156
- }
157
- function buildObjectId(userId, category, filePath) {
158
- const ext = getFileExtension(filePath);
159
- const suffix = ext ? `.${ext}` : '';
160
- return `assets/${userId}/${category}/${generateId()}${suffix}`;
161
- }
162
- function generateThumbnailKey(originalKey) {
163
- const lastSlashIndex = originalKey.lastIndexOf('/');
164
- if (lastSlashIndex === -1) {
165
- const filenameWithoutExt = originalKey.replace(/\.[^.]+$/, '');
166
- return `thumb/${filenameWithoutExt}_thumbnail.webp`;
167
- }
168
- const dirPath = originalKey.slice(0, lastSlashIndex);
169
- const filename = originalKey.slice(lastSlashIndex + 1);
170
- const filenameWithoutExt = filename.replace(/\.[^.]+$/, '');
171
- return `${dirPath}/thumb/${filenameWithoutExt}_thumbnail.webp`;
172
- }
173
- function buildStorageTarget(config, key) {
174
- assertStorageKeyUrlSafe(key);
175
- const resolvedEndpoint = finishStorageHost(config.endpoint);
176
- if (config.customDomain) {
177
- const domain = finishStorageHost(config.customDomain);
178
- return {
179
- storageUrl: `${domain}/${encodeStorageKeyForUrl(key)}`,
180
- domain,
181
- };
182
- }
183
- const target = config.forcePathStyle
184
- ? buildPathStyleUrl(config.bucket, resolvedEndpoint, key)
185
- : buildVirtualHostedStyleUrl(config.bucket, resolvedEndpoint, key);
186
- return {
187
- storageUrl: target.finalUrl,
188
- domain: target.domain,
189
- };
190
- }
191
- function isOfficialStorageProvider(provider) {
192
- return provider === OFFICIAL_STORAGE_PROVIDER;
193
- }
194
- function getStorageProviderName(provider) {
195
- if (isOfficialStorageProvider(provider)) {
196
- return OFFICIAL_STORAGE_PROVIDER_NAME;
197
- }
198
- switch (provider) {
199
- case 'bitiful':
200
- return '缤纷云';
201
- case 'tencent':
202
- return '腾讯云';
203
- case 'aliyun':
204
- return '阿里云';
205
- case 'aws':
206
- return 'AWS';
207
- case 'qiniu':
208
- return '七牛云';
209
- case 'cloudflare':
210
- return 'Cloudflare';
211
- default:
212
- return provider;
213
- }
214
- }
215
- function toSanitizedStorage(config, isActive) {
216
- return {
217
- id: config.id,
218
- aliasName: config.aliasName,
219
- provider: config.provider,
220
- endpoint: config.endpoint,
221
- bucket: config.bucket,
222
- region: config.region,
223
- forcePathStyle: config.forcePathStyle,
224
- pathPrefix: config.pathPrefix,
225
- customDomain: config.customDomain,
226
- legacyDomains: config.legacyDomains,
227
- isPrivate: config.isPrivate,
228
- hasAccessKeyId: config.accessKeyId.length > 0,
229
- hasSecretAccessKey: config.secretAccessKey.length > 0,
230
- isActive,
231
- };
232
- }
233
- function buildStorageUsageLabel(args) {
234
- const { provider, bucket, configsByKey, configsByProvider } = args;
235
- if (isOfficialStorageProvider(provider)) {
236
- return {
237
- label: OFFICIAL_STORAGE_PROVIDER_NAME,
238
- storageConfigId: null,
239
- aliasName: OFFICIAL_STORAGE_PROVIDER_NAME,
240
- isOfficial: true,
241
- };
242
- }
243
- const exactConfig = configsByKey.get(`${provider}::${bucket}`);
244
- if (exactConfig?.aliasName) {
245
- return {
246
- label: exactConfig.aliasName,
247
- storageConfigId: exactConfig.id,
248
- aliasName: exactConfig.aliasName,
249
- isOfficial: false,
250
- };
251
- }
252
- const sameProviderConfigs = configsByProvider.get(provider) ?? [];
253
- if (sameProviderConfigs.length === 1 && sameProviderConfigs[0]?.aliasName) {
254
- return {
255
- label: sameProviderConfigs[0].aliasName,
256
- storageConfigId: sameProviderConfigs[0].id,
257
- aliasName: sameProviderConfigs[0].aliasName,
258
- isOfficial: false,
259
- };
260
- }
261
- return {
262
- label: bucket ? `${getStorageProviderName(provider)} · ${bucket}` : getStorageProviderName(provider),
263
- storageConfigId: exactConfig?.id ?? null,
264
- aliasName: exactConfig?.aliasName ?? null,
265
- isOfficial: false,
266
- };
267
- }
268
- async function getTableColumns(db, tableName) {
269
- const rows = await db.getAll(`PRAGMA table_info(${tableName})`, []);
270
- return new Set(rows.map((row) => row.name).filter(Boolean));
271
- }
272
- async function listStoredCustomStorageConfigs(db, userId) {
273
- const rows = await db.getAll(`
274
- SELECT
275
- TRIM(COALESCE(id, '')) AS id,
276
- TRIM(COALESCE(alias_name, '')) AS alias_name,
277
- TRIM(COALESCE(protocol, '')) AS protocol,
278
- TRIM(COALESCE(provider, '')) AS provider,
279
- TRIM(COALESCE(endpoint, '')) AS endpoint,
280
- TRIM(COALESCE(bucket, '')) AS bucket,
281
- TRIM(COALESCE(region, '')) AS region,
282
- TRIM(COALESCE(access_key_id, '')) AS access_key_id,
283
- TRIM(COALESCE(secret_access_key, '')) AS secret_access_key,
284
- force_path_style,
285
- TRIM(COALESCE(path_prefix, '')) AS path_prefix,
286
- TRIM(COALESCE(custom_domain, '')) AS custom_domain,
287
- legacy_domains,
288
- is_private,
289
- updated_at,
290
- deleted_at
291
- FROM c_storage
292
- WHERE TRIM(COALESCE(user_id, '')) = ?
293
- AND TRIM(COALESCE(protocol, '')) = ?
294
- AND deleted_at IS NULL
295
- AND TRIM(COALESCE(id, '')) <> ''
296
- ORDER BY COALESCE(sort_rank, 2147483647) ASC, updated_at DESC, id ASC
297
- `, [userId, DEFAULT_STORAGE_PROTOCOL]);
298
- return rows
299
- .map((row) => ({
300
- id: normalizeText(row.id),
301
- aliasName: normalizeText(row.alias_name),
302
- provider: normalizeText(row.provider),
303
- endpoint: normalizeText(row.endpoint),
304
- bucket: normalizeText(row.bucket),
305
- region: normalizeText(row.region),
306
- accessKeyId: normalizeText(row.access_key_id),
307
- secretAccessKey: normalizeText(row.secret_access_key),
308
- forcePathStyle: normalizeBool(row.force_path_style),
309
- pathPrefix: normalizeStoragePathPrefix(normalizeText(row.path_prefix)),
310
- customDomain: normalizeText(row.custom_domain),
311
- legacyDomains: normalizeTextArray(row.legacy_domains),
312
- isPrivate: normalizeBool(row.is_private),
313
- }))
314
- .filter((row) => row.id.length > 0);
315
- }
316
- async function getActiveCustomStorageConfigId(db, userId) {
317
- const rows = await db.getAll(`
318
- SELECT c_value
319
- FROM c_config_property
320
- WHERE TRIM(COALESCE(c_key, '')) = ?
321
- AND TRIM(COALESCE(user_id, '')) = ?
322
- AND (is_del IS NULL OR is_del = 0)
323
- ORDER BY updated_at DESC, created_at DESC, id DESC
324
- LIMIT 1
325
- `, [ACTIVE_CUSTOM_STORAGE_CONFIG_ID_KEY, userId]);
326
- return normalizeText(rows[0]?.c_value ?? null) || null;
327
- }
328
- function resolveStorageSelection(args) {
329
- const explicitId = normalizeText(args.storageId);
330
- if (explicitId) {
331
- const config = args.configs.find((item) => item.id === explicitId);
332
- if (!config) {
333
- throw new DinoxError(`Storage config not found: ${explicitId}`, {
334
- suggestion: 'Run `dino storage list --format json` to inspect available storage ids.',
335
- });
336
- }
337
- return { config, selectionSource: 'storage_id' };
338
- }
339
- const activeId = normalizeText(args.activeCustomStorageConfigId);
340
- if (activeId) {
341
- const config = args.configs.find((item) => item.id === activeId);
342
- if (config) {
343
- return { config, selectionSource: 'active' };
344
- }
345
- }
346
- if (args.configs.length === 1) {
347
- return { config: args.configs[0], selectionSource: 'single_available_config' };
348
- }
349
- throw new DinoxError('No active custom storage config is available for upload.', {
350
- suggestion: 'Set an active custom storage config in the app, or pass `--storage-id <id>`.',
351
- });
352
- }
353
- function buildStorageUploadPlan(args) {
354
- const storageKey = normalizeText(args.key) || `${args.config.pathPrefix}${buildObjectId(args.userId, args.category, args.filePath)}`;
355
- const target = buildStorageTarget(args.config, storageKey);
356
- return {
357
- storage: toSanitizedStorage({
358
- ...args.config,
359
- endpoint: finishStorageHost(args.config.endpoint),
360
- }, args.selectionSource === 'active'),
361
- selectionSource: args.selectionSource,
362
- filePath: args.filePath,
363
- fileName: path.basename(args.filePath),
364
- sizeBytes: args.sizeBytes,
365
- category: args.category,
366
- contentType: guessContentType(args.filePath),
367
- storageKey,
368
- storageUrl: target.storageUrl,
369
- domain: target.domain,
370
- };
371
- }
372
- function buildStorageTestPlan(args) {
373
- const storageKey = `${args.config.pathPrefix}dinox-cli-test/${args.userId}/${generateId()}.txt`;
374
- const target = buildStorageTarget(args.config, storageKey);
375
- const payload = Buffer.from(`dinox-cli storage test\nprovider=${args.config.provider}\nuser=${args.userId}\ncreated_at=${new Date().toISOString()}\n`, 'utf8');
376
- return {
377
- storage: toSanitizedStorage({
378
- ...args.config,
379
- endpoint: finishStorageHost(args.config.endpoint),
380
- }, args.selectionSource === 'active'),
381
- selectionSource: args.selectionSource,
382
- contentType: 'text/plain',
383
- sizeBytes: payload.byteLength,
384
- storageKey,
385
- storageUrl: target.storageUrl,
386
- domain: target.domain,
387
- };
388
- }
389
- async function defaultUploadObject(input) {
390
- const { PutObjectCommand, S3Client } = await import('@aws-sdk/client-s3');
391
- const client = new S3Client({
392
- endpoint: finishStorageHost(input.storage.endpoint),
393
- region: input.storage.region,
394
- forcePathStyle: input.storage.forcePathStyle,
395
- credentials: {
396
- accessKeyId: input.storage.accessKeyId,
397
- secretAccessKey: input.storage.secretAccessKey,
398
- },
399
- });
400
- const response = await client.send(new PutObjectCommand({
401
- Bucket: input.bucket,
402
- Key: input.key,
403
- Body: fs.createReadStream(input.localPath),
404
- ContentType: input.contentType,
405
- ContentLength: input.contentLength,
406
- IfNoneMatch: input.overwrite ? undefined : '*',
407
- }));
408
- return {
409
- etag: normalizeText(response.ETag) || null,
410
- };
411
- }
412
- function isObjectCollisionError(error) {
413
- if (!error || typeof error !== 'object') {
414
- return false;
415
- }
416
- const record = error;
417
- return record.$metadata?.httpStatusCode === 409
418
- || record.$metadata?.httpStatusCode === 412
419
- || record.name === 'ConditionalRequestConflict'
420
- || record.name === 'PreconditionFailed'
421
- || record.Code === 'PreconditionFailed'
422
- || record.code === 'PreconditionFailed';
423
- }
424
- function createStorageCollisionError(bucket, keys, failureDetails = {}) {
425
- return new DinoxError(`Storage object already exists: ${keys.join(', ')}`, {
426
- code: 'STORAGE_OBJECT_ALREADY_EXISTS',
427
- suggestion: 'Choose a different --key, or pass --overwrite to replace the existing object.',
428
- recoverable: true,
429
- details: {
430
- bucket,
431
- keys,
432
- ...failureDetails,
433
- },
434
- });
435
- }
436
- function createStorageUploadOutcomeUnknownError(input) {
437
- return new DinoxError(input.message, {
438
- cause: input.cause,
439
- code: 'STORAGE_UPLOAD_OUTCOME_UNKNOWN',
440
- recoverable: true,
441
- suggestion: 'Inspect the reported object key in remote storage before retrying; do not assume the failed request left the object unchanged.',
442
- details: {
443
- bucket: input.bucket,
444
- storageKey: input.storageKey,
445
- operation: input.operation,
446
- overwrite: input.overwrite,
447
- remoteOutcome: 'unknown',
448
- remoteObjectChanged: null,
449
- affectedKeys: [],
450
- possiblyAffectedKeys: [...new Set(input.possiblyAffectedKeys)],
451
- },
452
- });
453
- }
454
- async function defaultDeleteObject(input) {
455
- const { DeleteObjectCommand, S3Client } = await import('@aws-sdk/client-s3');
456
- const client = new S3Client({
457
- endpoint: finishStorageHost(input.storage.endpoint),
458
- region: input.storage.region,
459
- forcePathStyle: input.storage.forcePathStyle,
460
- credentials: {
461
- accessKeyId: input.storage.accessKeyId,
462
- secretAccessKey: input.storage.secretAccessKey,
463
- },
464
- });
465
- await client.send(new DeleteObjectCommand({
466
- Bucket: input.bucket,
467
- Key: input.key,
468
- IfMatch: normalizeText(input.etag) || undefined,
469
- }));
470
- }
471
- async function defaultUploadBytes(input) {
472
- const { PutObjectCommand, S3Client } = await import('@aws-sdk/client-s3');
473
- const client = new S3Client({
474
- endpoint: finishStorageHost(input.storage.endpoint),
475
- region: input.storage.region,
476
- forcePathStyle: input.storage.forcePathStyle,
477
- credentials: {
478
- accessKeyId: input.storage.accessKeyId,
479
- secretAccessKey: input.storage.secretAccessKey,
480
- },
481
- });
482
- const response = await client.send(new PutObjectCommand({
483
- Bucket: input.bucket,
484
- Key: input.key,
485
- Body: input.body,
486
- ContentType: input.contentType,
487
- ContentLength: input.body.byteLength,
488
- IfNoneMatch: input.overwrite ? undefined : '*',
489
- }));
490
- return {
491
- etag: normalizeText(response.ETag) || null,
492
- };
493
- }
494
- async function generateThumbnailBuffer(localPath) {
495
- const { default: sharp } = await import('sharp');
496
- return sharp(localPath)
497
- .resize({ width: 400, withoutEnlargement: true })
498
- .webp({ quality: 50 })
499
- .toBuffer();
500
- }
501
- function buildUploadedResourceEntries(columns, args, id, now) {
502
- const entries = [];
503
- const push = (column, value) => {
504
- if (columns.has(column)) {
505
- entries.push([column, value]);
506
- }
507
- };
508
- push('id', id);
509
- push('user_id', args.userId);
510
- push('name', args.plan.fileName);
511
- push('mime_type', args.plan.contentType);
512
- push('size_bytes', args.plan.sizeBytes);
513
- push('storage_config_id', args.storage.id);
514
- push('storage_key', args.plan.storageKey);
515
- push('storage_url', args.plan.storageUrl);
516
- push('storage_provider', args.storage.provider);
517
- push('bucket', args.storage.bucket);
518
- push('is_public', args.storage.isPrivate ? 0 : 1);
519
- push('created_at', now);
520
- push('updated_at', now);
521
- push('is_del', 0);
522
- push('status', 'uploaded');
523
- push('uploaded_at', now);
524
- push('etag', args.etag);
525
- push('checksum', null);
526
- push('category', args.plan.category);
527
- push('endpoint', args.storage.endpoint);
528
- push('domain', args.plan.domain);
529
- push('extra_data', '{}');
530
- push('deleted_at', null);
531
- push('content_sha256', null);
532
- push('content', null);
533
- push('content_md', null);
534
- push('summary', null);
535
- return entries;
536
- }
537
- async function insertResourceRow(db, entries) {
538
- const insertColumns = entries.map(([column]) => column);
539
- const placeholders = insertColumns.map(() => '?').join(', ');
540
- await db.execute(`
541
- INSERT INTO c_resource (${insertColumns.join(', ')})
542
- VALUES (${placeholders})
543
- `, entries.map(([, value]) => value));
544
- }
545
- async function persistUploadedResource(db, args) {
546
- const columns = await getTableColumns(db, 'c_resource');
547
- const requiredColumns = [
548
- 'id',
549
- 'user_id',
550
- 'name',
551
- 'mime_type',
552
- 'size_bytes',
553
- 'storage_key',
554
- 'storage_url',
555
- 'storage_provider',
556
- 'bucket',
557
- 'created_at',
558
- 'updated_at',
559
- 'is_del',
560
- ];
561
- const missingRequired = requiredColumns.filter((column) => !columns.has(column));
562
- if (missingRequired.length > 0) {
563
- throw new DinoxError(`c_resource table is missing required columns: ${missingRequired.join(', ')}`);
564
- }
565
- const id = generateId();
566
- const now = new Date().toISOString();
567
- const entries = buildUploadedResourceEntries(columns, args, id, now);
568
- if (!args.overwrite) {
569
- await insertResourceRow(db, entries);
570
- return id;
571
- }
572
- return withPowerSyncWriteTransaction(db, async (tx) => {
573
- const identityClauses = [
574
- 'user_id = ?',
575
- 'storage_key = ?',
576
- 'COALESCE(is_del, 0) = 0',
577
- ];
578
- const identityParams = [args.userId, args.plan.storageKey];
579
- if (columns.has('storage_config_id')) {
580
- identityClauses.push('storage_config_id = ?');
581
- identityParams.push(args.storage.id);
582
- }
583
- else {
584
- identityClauses.push('storage_provider = ?', 'bucket = ?');
585
- identityParams.push(args.storage.provider, args.storage.bucket);
586
- if (columns.has('endpoint')) {
587
- identityClauses.push('endpoint = ?');
588
- identityParams.push(args.storage.endpoint);
589
- }
590
- }
591
- const existingRows = await tx.getAll(`
592
- SELECT id
593
- FROM c_resource
594
- WHERE ${identityClauses.join('\n AND ')}
595
- ORDER BY created_at ASC, id ASC
596
- `, identityParams);
597
- const existingIds = [...new Set(existingRows.map((row) => normalizeText(row.id)).filter(Boolean))];
598
- const existingId = existingIds[0];
599
- if (!existingId) {
600
- await insertResourceRow(tx, entries);
601
- return id;
602
- }
603
- const updateEntries = entries.filter(([column]) => (column !== 'id' && column !== 'user_id' && column !== 'created_at'));
604
- await tx.execute(`
605
- UPDATE c_resource
606
- SET ${updateEntries.map(([column]) => `${column} = ?`).join(', ')}
607
- WHERE id = ?
608
- AND user_id = ?
609
- AND COALESCE(is_del, 0) = 0
610
- `, [...updateEntries.map(([, value]) => value), existingId, args.userId]);
611
- const duplicateIds = existingIds.slice(1);
612
- if (duplicateIds.length > 0) {
613
- const duplicateUpdates = [
614
- ['updated_at', now],
615
- ['is_del', 1],
616
- ...(columns.has('deleted_at') ? [['deleted_at', now]] : []),
617
- ];
618
- await tx.execute(`
619
- UPDATE c_resource
620
- SET ${duplicateUpdates.map(([column]) => `${column} = ?`).join(', ')}
621
- WHERE user_id = ?
622
- AND COALESCE(is_del, 0) = 0
623
- AND id IN (${duplicateIds.map(() => '?').join(', ')})
624
- `, [...duplicateUpdates.map(([, value]) => value), args.userId, ...duplicateIds]);
625
- }
626
- return existingId;
627
- });
628
- }
629
- export function parseStorageUploadCategory(value) {
630
- const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
631
- if (UPLOAD_CATEGORY_VALUES.includes(normalized)) {
632
- return normalized;
633
- }
634
- throw new DinoxError(`--category must be one of: ${UPLOAD_CATEGORY_VALUES.join(', ')}`);
635
- }
636
- export async function listStorageConfigs(db, userId) {
637
- const activeCustomStorageConfigId = await getActiveCustomStorageConfigId(db, userId);
638
- const configs = await listStoredCustomStorageConfigs(db, userId);
639
- return {
640
- activeCustomStorageConfigId,
641
- usesOfficialStorage: !activeCustomStorageConfigId,
642
- items: configs.map((config) => toSanitizedStorage(config, config.id === activeCustomStorageConfigId)),
643
- };
644
- }
645
- export async function getStorageStats(db, userId) {
646
- const rows = await db.getAll(`
647
- SELECT
648
- TRIM(COALESCE(storage_provider, '')) AS provider,
649
- TRIM(COALESCE(bucket, '')) AS bucket,
650
- COALESCE(SUM(COALESCE(size_bytes, 0)), 0) AS total_bytes,
651
- COUNT(1) AS file_count
652
- FROM c_resource
653
- WHERE (is_del IS NULL OR is_del = 0)
654
- AND TRIM(COALESCE(user_id, '')) = ?
655
- AND TRIM(COALESCE(storage_provider, '')) <> ''
656
- AND TRIM(COALESCE(storage_provider, '')) <> 'local'
657
- AND TRIM(COALESCE(status, '')) = 'uploaded'
658
- GROUP BY storage_provider, bucket
659
- ORDER BY total_bytes DESC, provider ASC, bucket ASC
660
- `, [userId]);
661
- const configs = await listStoredCustomStorageConfigs(db, userId);
662
- const configsByKey = new Map();
663
- const configsByProvider = new Map();
664
- for (const config of configs) {
665
- configsByKey.set(`${config.provider}::${config.bucket}`, config);
666
- const current = configsByProvider.get(config.provider);
667
- if (current) {
668
- current.push(config);
669
- }
670
- else {
671
- configsByProvider.set(config.provider, [config]);
672
- }
673
- }
674
- const entries = rows
675
- .map((row) => {
676
- const provider = normalizeText(row.provider);
677
- const bucket = normalizeText(row.bucket);
678
- const labelInfo = buildStorageUsageLabel({
679
- provider,
680
- bucket,
681
- configsByKey,
682
- configsByProvider,
683
- });
684
- return {
685
- provider,
686
- bucket,
687
- label: labelInfo.label,
688
- totalBytes: normalizeNumber(row.total_bytes),
689
- fileCount: normalizeNumber(row.file_count),
690
- storageConfigId: labelInfo.storageConfigId,
691
- aliasName: labelInfo.aliasName,
692
- isOfficial: labelInfo.isOfficial,
693
- };
694
- })
695
- .filter((entry) => entry.provider.length > 0 && entry.fileCount > 0)
696
- .sort((left, right) => {
697
- if (left.isOfficial !== right.isOfficial) {
698
- return left.isOfficial ? -1 : 1;
699
- }
700
- return right.totalBytes - left.totalBytes;
701
- });
702
- return {
703
- totalBytes: entries.reduce((sum, entry) => sum + entry.totalBytes, 0),
704
- totalFiles: entries.reduce((sum, entry) => sum + entry.fileCount, 0),
705
- entries,
706
- };
707
- }
708
- export async function inspectStorageUpload(db, input, deps) {
709
- const statFile = deps?.statFile ?? fsp.stat;
710
- const filePath = input.filePath.trim();
711
- if (!filePath) {
712
- throw new DinoxError('File path is required.');
713
- }
714
- const stats = await statFile(filePath).catch((error) => {
715
- throw new DinoxError(`File not found: ${filePath}`, { cause: error });
716
- });
717
- if (!stats.isFile()) {
718
- throw new DinoxError(`Not a regular file: ${filePath}`);
719
- }
720
- const configs = await listStoredCustomStorageConfigs(db, input.userId);
721
- if (configs.length === 0) {
722
- throw new DinoxError('No custom storage configs are available.', {
723
- suggestion: 'Configure a custom storage target in the app first.',
724
- });
725
- }
726
- const activeCustomStorageConfigId = await getActiveCustomStorageConfigId(db, input.userId);
727
- const { config, selectionSource } = resolveStorageSelection({
728
- configs,
729
- storageId: input.storageId,
730
- activeCustomStorageConfigId,
731
- });
732
- if (!config.endpoint || !config.bucket || !config.region || !config.accessKeyId || !config.secretAccessKey) {
733
- throw new DinoxError(`Storage config "${config.aliasName || config.id}" is incomplete for upload.`, {
734
- suggestion: 'Fill in endpoint, region, bucket, access key id, and secret access key in the app.',
735
- });
736
- }
737
- return buildStorageUploadPlan({
738
- config,
739
- selectionSource,
740
- userId: input.userId,
741
- filePath,
742
- category: input.category,
743
- key: input.key,
744
- sizeBytes: stats.size,
745
- });
746
- }
747
- export async function inspectStorageTest(db, input) {
748
- const configs = await listStoredCustomStorageConfigs(db, input.userId);
749
- if (configs.length === 0) {
750
- throw new DinoxError('No custom storage configs are available.', {
751
- suggestion: 'Configure a custom storage target in the app first.',
752
- });
753
- }
754
- const activeCustomStorageConfigId = await getActiveCustomStorageConfigId(db, input.userId);
755
- const { config, selectionSource } = resolveStorageSelection({
756
- configs,
757
- storageId: input.storageId,
758
- activeCustomStorageConfigId,
759
- });
760
- if (!config.endpoint || !config.bucket || !config.region || !config.accessKeyId || !config.secretAccessKey) {
761
- throw new DinoxError(`Storage config "${config.aliasName || config.id}" is incomplete for upload.`, {
762
- suggestion: 'Fill in endpoint, region, bucket, access key id, and secret access key in the app.',
763
- });
764
- }
765
- return buildStorageTestPlan({
766
- config,
767
- selectionSource,
768
- userId: input.userId,
769
- });
770
- }
771
- export async function testStorageUpload(db, input, deps) {
772
- const plan = await inspectStorageTest(db, input);
773
- const configs = await listStoredCustomStorageConfigs(db, input.userId);
774
- const storage = configs.find((item) => item.id === plan.storage.id);
775
- if (!storage) {
776
- throw new DinoxError(`Storage config not found: ${plan.storage.id}`);
777
- }
778
- const uploadBytes = deps?.uploadBytes ?? defaultUploadBytes;
779
- const deleteObject = deps?.deleteObject ?? defaultDeleteObject;
780
- const payload = Buffer.from(`dinox-cli storage test\nprovider=${storage.provider}\nuser=${input.userId}\ncreated_at=${new Date().toISOString()}\n`, 'utf8');
781
- let uploaded;
782
- try {
783
- uploaded = await uploadBytes({
784
- body: payload,
785
- bucket: storage.bucket,
786
- key: plan.storageKey,
787
- contentType: plan.contentType,
788
- storage,
789
- });
790
- }
791
- catch (error) {
792
- if (isObjectCollisionError(error)) {
793
- throw error;
794
- }
795
- throw createStorageUploadOutcomeUnknownError({
796
- message: 'Storage test upload outcome could not be confirmed.',
797
- cause: error,
798
- bucket: storage.bucket,
799
- storageKey: plan.storageKey,
800
- possiblyAffectedKeys: [plan.storageKey],
801
- overwrite: false,
802
- operation: 'storage_test',
803
- });
804
- }
805
- const cleanupEtag = normalizeText(uploaded.etag);
806
- if (!cleanupEtag) {
807
- throw new DinoxError('Storage test uploaded the temporary object but could not clean it up safely.', {
808
- code: 'STORAGE_TEST_CLEANUP_FAILED',
809
- details: {
810
- storageKey: plan.storageKey,
811
- bucket: storage.bucket,
812
- remoteObjectChanged: true,
813
- affectedKeys: [plan.storageKey],
814
- cleanupErrors: [
815
- `${plan.storageKey} cleanup skipped: upload response did not include an ETag required for safe conditional deletion`,
816
- ],
817
- },
818
- });
819
- }
820
- try {
821
- await deleteObject({
822
- bucket: storage.bucket,
823
- key: plan.storageKey,
824
- etag: cleanupEtag,
825
- storage,
826
- });
827
- }
828
- catch (error) {
829
- throw new DinoxError('Storage test uploaded the temporary object but failed to clean it up.', {
830
- cause: error,
831
- code: 'STORAGE_TEST_CLEANUP_FAILED',
832
- details: {
833
- storageKey: plan.storageKey,
834
- bucket: storage.bucket,
835
- remoteObjectChanged: true,
836
- affectedKeys: [plan.storageKey],
837
- cleanupErrors: [
838
- `${plan.storageKey} cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
839
- ],
840
- },
841
- });
842
- }
843
- return {
844
- storageConfigId: storage.id,
845
- aliasName: storage.aliasName,
846
- provider: storage.provider,
847
- bucket: storage.bucket,
848
- endpoint: storage.endpoint,
849
- domain: plan.domain,
850
- storageKey: plan.storageKey,
851
- storageUrl: plan.storageUrl,
852
- contentType: plan.contentType,
853
- sizeBytes: plan.sizeBytes,
854
- selectionSource: plan.selectionSource,
855
- isPrivate: storage.isPrivate,
856
- };
857
- }
858
- export async function uploadFileToStorage(db, input, deps) {
859
- if (input.overwrite && !normalizeText(input.key)) {
860
- throw new DinoxError('--overwrite requires an explicit --key.', {
861
- code: 'USAGE_ERROR',
862
- });
863
- }
864
- const plan = await inspectStorageUpload(db, input, { statFile: deps?.statFile });
865
- const configs = await listStoredCustomStorageConfigs(db, input.userId);
866
- const storage = configs.find((item) => item.id === plan.storage.id);
867
- if (!storage) {
868
- throw new DinoxError(`Storage config not found: ${plan.storage.id}`);
869
- }
870
- const overwrite = input.overwrite === true;
871
- const uploadObject = deps?.uploadObject ?? defaultUploadObject;
872
- const uploadBytes = deps?.uploadBytes ?? defaultUploadBytes;
873
- const deleteObject = deps?.deleteObject ?? defaultDeleteObject;
874
- const thumbnailKey = plan.contentType.startsWith('image/')
875
- ? generateThumbnailKey(plan.storageKey)
876
- : null;
877
- let uploaded;
878
- try {
879
- uploaded = await uploadObject({
880
- localPath: plan.filePath,
881
- bucket: storage.bucket,
882
- key: plan.storageKey,
883
- contentType: plan.contentType,
884
- contentLength: plan.sizeBytes,
885
- overwrite,
886
- storage,
887
- });
888
- }
889
- catch (error) {
890
- if (!overwrite && isObjectCollisionError(error)) {
891
- throw createStorageCollisionError(storage.bucket, [plan.storageKey]);
892
- }
893
- throw createStorageUploadOutcomeUnknownError({
894
- message: 'Storage upload outcome could not be confirmed.',
895
- cause: error,
896
- bucket: storage.bucket,
897
- storageKey: plan.storageKey,
898
- possiblyAffectedKeys: [plan.storageKey],
899
- overwrite,
900
- operation: 'upload',
901
- });
902
- }
903
- let thumbnailUrl = null;
904
- const possiblyAffectedKeys = new Set();
905
- const remoteWrites = [{
906
- key: plan.storageKey,
907
- etag: uploaded.etag,
908
- cleanupAllowed: !overwrite,
909
- cleaned: false,
910
- }];
911
- const cleanupUploadedObjects = async () => {
912
- const cleanupErrors = [];
913
- for (const remoteWrite of [...remoteWrites].reverse()) {
914
- if (!remoteWrite.cleanupAllowed || remoteWrite.cleaned) {
915
- continue;
916
- }
917
- const cleanupEtag = normalizeText(remoteWrite.etag);
918
- if (!cleanupEtag) {
919
- cleanupErrors.push(`${remoteWrite.key} cleanup skipped: upload response did not include an ETag required for safe conditional deletion`);
920
- continue;
921
- }
922
- await deleteObject({
923
- bucket: storage.bucket,
924
- key: remoteWrite.key,
925
- etag: cleanupEtag,
926
- storage,
927
- }).then(() => {
928
- remoteWrite.cleaned = true;
929
- }, (cleanupError) => {
930
- cleanupErrors.push(`${remoteWrite.key} cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
931
- });
932
- }
933
- return cleanupErrors;
934
- };
935
- const buildRemoteFailureDetails = (cleanupErrors) => {
936
- const affectedKeys = remoteWrites
937
- .filter((remoteWrite) => !remoteWrite.cleaned)
938
- .map((remoteWrite) => remoteWrite.key);
939
- const possibleKeys = [...possiblyAffectedKeys];
940
- return {
941
- bucket: storage.bucket,
942
- storageKey: plan.storageKey,
943
- remoteObjectChanged: possibleKeys.length > 0
944
- ? (affectedKeys.length > 0 ? true : null)
945
- : affectedKeys.length > 0,
946
- affectedKeys,
947
- ...(possibleKeys.length > 0
948
- ? { remoteOutcome: 'unknown', possiblyAffectedKeys: possibleKeys }
949
- : {}),
950
- ...(cleanupErrors.length > 0 ? { cleanupErrors } : {}),
951
- };
952
- };
953
- if (plan.contentType.startsWith('image/')) {
954
- try {
955
- if (!thumbnailKey) {
956
- throw new Error('Thumbnail key could not be generated.');
957
- }
958
- const thumbnailBuffer = await generateThumbnailBuffer(plan.filePath);
959
- const thumbnailTarget = buildStorageTarget(storage, thumbnailKey);
960
- let uploadedThumbnail;
961
- try {
962
- uploadedThumbnail = await uploadBytes({
963
- body: thumbnailBuffer,
964
- bucket: storage.bucket,
965
- key: thumbnailKey,
966
- contentType: 'image/webp',
967
- overwrite,
968
- storage,
969
- });
970
- }
971
- catch (error) {
972
- if (!isObjectCollisionError(error)) {
973
- possiblyAffectedKeys.add(thumbnailKey);
974
- }
975
- throw error;
976
- }
977
- remoteWrites.push({
978
- key: thumbnailKey,
979
- etag: uploadedThumbnail.etag,
980
- cleanupAllowed: !overwrite,
981
- cleaned: false,
982
- });
983
- thumbnailUrl = thumbnailTarget.storageUrl;
984
- }
985
- catch (error) {
986
- const cleanupErrors = await cleanupUploadedObjects();
987
- if (!overwrite && isObjectCollisionError(error) && thumbnailKey) {
988
- throw createStorageCollisionError(storage.bucket, [thumbnailKey], buildRemoteFailureDetails(cleanupErrors));
989
- }
990
- throw new DinoxError('Storage upload failed while generating the required thumbnail.', {
991
- cause: error,
992
- code: 'STORAGE_THUMBNAIL_UPLOAD_FAILED',
993
- details: buildRemoteFailureDetails(cleanupErrors),
994
- });
995
- }
996
- }
997
- try {
998
- const resourceId = await persistUploadedResource(db, {
999
- userId: input.userId,
1000
- storage,
1001
- plan,
1002
- etag: uploaded.etag,
1003
- thumbnailUrl,
1004
- overwrite,
1005
- });
1006
- return {
1007
- resourceId,
1008
- storageConfigId: storage.id,
1009
- aliasName: storage.aliasName,
1010
- provider: storage.provider,
1011
- bucket: storage.bucket,
1012
- endpoint: storage.endpoint,
1013
- domain: plan.domain,
1014
- storageKey: plan.storageKey,
1015
- storageUrl: plan.storageUrl,
1016
- fileName: plan.fileName,
1017
- sizeBytes: plan.sizeBytes,
1018
- contentType: plan.contentType,
1019
- category: plan.category,
1020
- etag: uploaded.etag,
1021
- thumbnailUrl,
1022
- isPrivate: storage.isPrivate,
1023
- selectionSource: plan.selectionSource,
1024
- };
1025
- }
1026
- catch (error) {
1027
- const cleanupErrors = await cleanupUploadedObjects();
1028
- throw new DinoxError('Storage upload failed while recording resource metadata.', {
1029
- cause: error,
1030
- code: 'STORAGE_RESOURCE_INSERT_FAILED',
1031
- details: buildRemoteFailureDetails(cleanupErrors),
1032
- });
1033
- }
1034
- }
1
+ // Public repository API. Implementation lives in ./repo/*; this module re-exports
2
+ // only the original public surface so callers do not depend on the internal split.
3
+ export { getStorageStats, listStorageConfigs, parseStorageUploadCategory, } from './repo/configs.js';
4
+ export { inspectStorageTest, inspectStorageUpload, testStorageUpload, uploadFileToStorage, } from './repo/operations.js';