@homecloud-platform/sdk 0.5.9 → 0.5.11

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homecloud-platform/sdk",
3
- "version": "0.5.9",
3
+ "version": "0.5.11",
4
4
  "description": "HomeCloud Functions SDK for Node.js (ADR-033 / ADR-025e)",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
package/src/client.js CHANGED
@@ -153,6 +153,10 @@ class HomeCloud {
153
153
  if (!this.accessToken) throw new NotLoggedInError();
154
154
  }
155
155
 
156
+ get hasAccessKey() {
157
+ return Boolean(this.accessKeyId && this.secretAccessKey);
158
+ }
159
+
156
160
  baseUrl(service) {
157
161
  if (this.dataPlaneBases[service]) return this.dataPlaneBases[service].replace(/\/$/, "");
158
162
  if (service === "so") return soUrl(this.apex);
@@ -320,6 +324,50 @@ class HomeCloud {
320
324
  return data;
321
325
  }
322
326
 
327
+ async consoleSignedRequest(method, pathSeg, { json, params } = {}) {
328
+ this.requireAccessKey();
329
+ await this.ensureAccountId();
330
+ const base = (this.consoleBaseUrl || consoleUrl(this.apex)).replace(/\/$/, "");
331
+ const rel = String(pathSeg).replace(/^\/+/, "");
332
+ const url = new URL(`${base}/${rel}`);
333
+ if (params) {
334
+ for (const [k, v] of Object.entries(params)) {
335
+ if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
336
+ }
337
+ }
338
+ const headers = signRequestHeaders({
339
+ accessKeyId: this.accessKeyId,
340
+ secret: this.secretAccessKey,
341
+ method,
342
+ path: `/api/v1/${rel}`,
343
+ accountId: this.accountId,
344
+ sessionToken: this.sessionToken,
345
+ });
346
+ const init = { method, headers, signal: AbortSignal.timeout(this.timeoutMs) };
347
+ if (json !== undefined) {
348
+ headers["Content-Type"] = "application/json";
349
+ init.body = JSON.stringify(json);
350
+ }
351
+ const res = await fetch(url, init);
352
+ if (method === "DELETE" && res.status === 204) return null;
353
+ const text = await res.text();
354
+ let data = null;
355
+ if (text) {
356
+ try {
357
+ data = JSON.parse(text);
358
+ } catch (_) {
359
+ data = { raw: text };
360
+ }
361
+ }
362
+ if (!res.ok) {
363
+ throw errorFromStatus(res.status, {
364
+ detail: data && (data.detail !== undefined ? data.detail : data),
365
+ url: String(url),
366
+ });
367
+ }
368
+ return data;
369
+ }
370
+
323
371
  async consoleRequestBytes(method, pathSeg) {
324
372
  this.requireConsole();
325
373
  const base = (this.consoleBaseUrl || consoleUrl(this.apex)).replace(/\/$/, "");
package/src/management.js CHANGED
@@ -40,21 +40,26 @@ class QueuesAPI {
40
40
  }
41
41
 
42
42
  async list({ live = false } = {}) {
43
+ await this._c.ensureAccountId();
44
+ const path = `accounts/${this._c.accountId}/queues`;
45
+ const params = live ? { live: "true" } : undefined;
46
+ if (this._c.hasAccessKey) {
47
+ const data = await this._c.consoleSignedRequest("GET", path, { params });
48
+ return data.items || [];
49
+ }
43
50
  this._c.requireConsole();
44
- const data = await this._c.consoleRequest(
45
- "GET",
46
- `accounts/${this._c.accountId}/queues`,
47
- { params: live ? { live: "true" } : undefined }
48
- );
51
+ const data = await this._c.consoleRequest("GET", path, { params });
49
52
  return data.items || [];
50
53
  }
51
54
 
52
55
  async get(queueName) {
56
+ await this._c.ensureAccountId();
57
+ const path = `accounts/${this._c.accountId}/queues/${encodeURIComponent(queueName)}`;
58
+ if (this._c.hasAccessKey) {
59
+ return this._c.consoleSignedRequest("GET", path);
60
+ }
53
61
  this._c.requireConsole();
54
- return this._c.consoleRequest(
55
- "GET",
56
- `accounts/${this._c.accountId}/queues/${encodeURIComponent(queueName)}`
57
- );
62
+ return this._c.consoleRequest("GET", path);
58
63
  }
59
64
  }
60
65
 
package/src/so.js CHANGED
@@ -40,6 +40,15 @@ class SoAPI {
40
40
  }
41
41
 
42
42
  async listBuckets() {
43
+ await this._c.ensureAccountId();
44
+ if (this._c.hasAccessKey) {
45
+ const data = await this._c.dataPlaneRequest(
46
+ "so",
47
+ "GET",
48
+ `/${this._c.accountId}/buckets`
49
+ );
50
+ return data.items || [];
51
+ }
43
52
  this._c.requireConsole();
44
53
  const data = await this._c.consoleRequest(
45
54
  "GET",
@@ -49,18 +58,25 @@ class SoAPI {
49
58
  }
50
59
 
51
60
  async createBucket(name) {
61
+ await this._c.ensureAccountId();
62
+ const path = `accounts/${this._c.accountId}/storage/buckets`;
63
+ const body = { name: String(name).trim().toLowerCase() };
64
+ if (this._c.hasAccessKey) {
65
+ return this._c.consoleSignedRequest("POST", path, { json: body });
66
+ }
52
67
  this._c.requireConsole();
53
- return this._c.consoleRequest("POST", `accounts/${this._c.accountId}/storage/buckets`, {
54
- json: { name: String(name).trim().toLowerCase() },
55
- });
68
+ return this._c.consoleRequest("POST", path, { json: body });
56
69
  }
57
70
 
58
71
  async deleteBucket(name) {
72
+ await this._c.ensureAccountId();
73
+ const path = `accounts/${this._c.accountId}/storage/buckets/${String(name).trim().toLowerCase()}`;
74
+ if (this._c.hasAccessKey) {
75
+ await this._c.consoleSignedRequest("DELETE", path);
76
+ return;
77
+ }
59
78
  this._c.requireConsole();
60
- await this._c.consoleRequest(
61
- "DELETE",
62
- `accounts/${this._c.accountId}/storage/buckets/${String(name).trim().toLowerCase()}`
63
- );
79
+ await this._c.consoleRequest("DELETE", path);
64
80
  }
65
81
 
66
82
  async listObjects(bucketName, { prefix = "", recursive = false, page = 1, pageSize = 100 } = {}) {
@@ -213,6 +229,19 @@ class SoAPI {
213
229
  };
214
230
  }
215
231
 
232
+ async copy(bucketName, sourceKey, destinationKey, { sourceBucket = null } = {}) {
233
+ this._c.requireAccessKey();
234
+ const { signPath, urlPath } = soObjectPaths(this._c.accountId, bucketName, sourceKey);
235
+ return this._c.dataPlaneRequest("so", "POST", `${signPath}/copy`, {
236
+ urlPath: `${urlPath}/copy`,
237
+ signPath: `${signPath}/copy`,
238
+ json: {
239
+ destination_key: destinationKey,
240
+ source_bucket: sourceBucket || null,
241
+ },
242
+ });
243
+ }
244
+
216
245
  async deleteRecursive(bucketName, prefix = "") {
217
246
  const items = await this.listAllObjects(bucketName, { prefix, recursive: true });
218
247
  for (const item of items) {
@@ -221,7 +250,135 @@ class SoAPI {
221
250
  return items.length;
222
251
  }
223
252
 
224
- async syncLocalToBucket(localDir, bucketName, { prefix = "", deleteExtra = false } = {}) {
253
+ _isSoUri(target) {
254
+ const lowered = String(target || "").toLowerCase();
255
+ return lowered.startsWith("so://") || lowered.startsWith("s3://");
256
+ }
257
+
258
+ _parseSoUri(target) {
259
+ let text = String(target || "").trim();
260
+ const lowered = text.toLowerCase();
261
+ if (lowered.startsWith("so://")) text = text.slice(5);
262
+ else if (lowered.startsWith("s3://")) text = text.slice(5);
263
+ text = text.replace(/^\/+|\/+$/g, "");
264
+ if (!text) throw new HomeCloudError("URI must include a bucket name");
265
+ const slash = text.indexOf("/");
266
+ if (slash < 0) return { bucket: text, prefix: "" };
267
+ return { bucket: text.slice(0, slash), prefix: text.slice(slash + 1) };
268
+ }
269
+
270
+ _syncJoinPrefix(prefixClean, relative) {
271
+ const rel = String(relative || "").replace(/^\/+/, "");
272
+ if (!prefixClean) return rel;
273
+ if (!rel) return prefixClean;
274
+ return `${prefixClean}/${rel}`;
275
+ }
276
+
277
+ _syncRelativePath(key, prefixClean) {
278
+ if (!prefixClean) return key;
279
+ if (key === prefixClean) return key.split("/").pop();
280
+ if (key.startsWith(`${prefixClean}/`)) return key.slice(prefixClean.length + 1);
281
+ return key;
282
+ }
283
+
284
+ /**
285
+ * Unified sync: local↔bucket or bucket↔bucket.
286
+ * Prefer this over syncLocalToBucket / syncBucketToLocal.
287
+ */
288
+ async sync(source, destination, { deleteExtra = false, skip = false } = {}) {
289
+ const srcRemote = this._isSoUri(source);
290
+ const dstRemote = this._isSoUri(destination);
291
+ if (srcRemote && dstRemote) {
292
+ const src = this._parseSoUri(source);
293
+ const dst = this._parseSoUri(destination);
294
+ return this._syncBucketToBucket(src.bucket, dst.bucket, {
295
+ sourcePrefix: src.prefix,
296
+ destinationPrefix: dst.prefix,
297
+ deleteExtra,
298
+ skip,
299
+ });
300
+ }
301
+ if (srcRemote && !dstRemote) {
302
+ const src = this._parseSoUri(source);
303
+ return this.syncBucketToLocal(src.bucket, destination, {
304
+ prefix: src.prefix,
305
+ deleteExtra,
306
+ skip,
307
+ });
308
+ }
309
+ if (!srcRemote && dstRemote) {
310
+ const dst = this._parseSoUri(destination);
311
+ return this.syncLocalToBucket(source, dst.bucket, {
312
+ prefix: dst.prefix,
313
+ deleteExtra,
314
+ skip,
315
+ });
316
+ }
317
+ throw new HomeCloudError(
318
+ "One or both sides must be an so:// URI (local↔bucket or bucket↔bucket)"
319
+ );
320
+ }
321
+
322
+ async _syncBucketToBucket(
323
+ sourceBucket,
324
+ destinationBucket,
325
+ { sourcePrefix = "", destinationPrefix = "", deleteExtra = false, skip = false } = {}
326
+ ) {
327
+ this._c.requireAccessKey();
328
+ const srcPrefix = String(sourcePrefix || "").replace(/^\/+|\/+$/g, "");
329
+ const dstPrefix = String(destinationPrefix || "").replace(/^\/+|\/+$/g, "");
330
+ if (sourceBucket === destinationBucket && srcPrefix === dstPrefix) {
331
+ throw new HomeCloudError(`Source and destination are the same: so://${sourceBucket}/${srcPrefix}`);
332
+ }
333
+
334
+ const sourceItems = await this.listAllObjects(sourceBucket, {
335
+ prefix: srcPrefix,
336
+ recursive: true,
337
+ });
338
+ const destItems = await this.listAllObjects(destinationBucket, {
339
+ prefix: dstPrefix,
340
+ recursive: true,
341
+ });
342
+
343
+ const sourceRels = new Map();
344
+ for (const item of sourceItems) {
345
+ const rel = this._syncRelativePath(item.key, srcPrefix);
346
+ sourceRels.set(rel, { key: item.key, size: Number(item.size || 0) });
347
+ }
348
+ const destRels = new Map();
349
+ for (const item of destItems) {
350
+ const rel = this._syncRelativePath(item.key, dstPrefix);
351
+ destRels.set(rel, { key: item.key, size: Number(item.size || 0) });
352
+ }
353
+
354
+ let copied = 0;
355
+ let skipped = 0;
356
+ for (const [rel, src] of sourceRels) {
357
+ const dest = destRels.get(rel);
358
+ if (skip && dest && dest.size === src.size) {
359
+ skipped += 1;
360
+ continue;
361
+ }
362
+ const dstKey = this._syncJoinPrefix(dstPrefix, rel);
363
+ await this.copy(destinationBucket, src.key, dstKey, {
364
+ sourceBucket: sourceBucket !== destinationBucket ? sourceBucket : null,
365
+ });
366
+ copied += 1;
367
+ }
368
+
369
+ let deleted = 0;
370
+ if (deleteExtra) {
371
+ for (const [rel, dest] of destRels) {
372
+ if (sourceRels.has(rel)) continue;
373
+ await this.delete(destinationBucket, dest.key);
374
+ deleted += 1;
375
+ }
376
+ }
377
+ return { copied, skipped, deleted };
378
+ }
379
+
380
+ /** Prefer sync("./dir", "so://bucket/prefix"). */
381
+ async syncLocalToBucket(localDir, bucketName, { prefix = "", deleteExtra = false, skip = false } = {}) {
225
382
  this._c.requireAccessKey();
226
383
  const root = path.resolve(localDir);
227
384
  const prefixClean = String(prefix || "").replace(/^\/+|\/+$/g, "");
@@ -232,43 +389,83 @@ class SoAPI {
232
389
  const st = fs.statSync(full);
233
390
  const rel = path.relative(base, full).split(path.sep).join("/");
234
391
  if (st.isDirectory()) out.push(...walk(full, base));
235
- else out.push(rel);
392
+ else out.push({ rel, size: st.size });
236
393
  }
237
394
  return out;
238
395
  };
239
396
  const locals = walk(root, root);
240
- for (const rel of locals) {
241
- const key = prefixClean ? `${prefixClean}/${rel}` : rel;
397
+ const remote = await this.listAllObjects(bucketName, { prefix: prefixClean, recursive: true });
398
+ const remoteByKey = new Map(remote.map((item) => [item.key, item]));
399
+
400
+ let uploaded = 0;
401
+ let skipped = 0;
402
+ for (const { rel, size } of locals) {
403
+ const key = this._syncJoinPrefix(prefixClean, rel);
404
+ const existing = remoteByKey.get(key);
405
+ if (skip && existing && Number(existing.size || 0) === size) {
406
+ skipped += 1;
407
+ continue;
408
+ }
242
409
  await this.upload(bucketName, path.join(root, rel), { key });
410
+ uploaded += 1;
243
411
  }
412
+ let deleted = 0;
244
413
  if (deleteExtra) {
245
- const remote = await this.listAllObjects(bucketName, { prefix: prefixClean, recursive: true });
246
- const localKeys = new Set(
247
- locals.map((rel) => (prefixClean ? `${prefixClean}/${rel}` : rel))
248
- );
414
+ const localKeys = new Set(locals.map(({ rel }) => this._syncJoinPrefix(prefixClean, rel)));
249
415
  for (const item of remote) {
250
- if (!localKeys.has(item.key)) await this.delete(bucketName, item.key);
416
+ if (!localKeys.has(item.key)) {
417
+ await this.delete(bucketName, item.key);
418
+ deleted += 1;
419
+ }
251
420
  }
252
421
  }
253
- return { uploaded: locals.length };
422
+ return { uploaded, skipped, deleted };
254
423
  }
255
424
 
256
- async syncBucketToLocal(bucketName, localDir, { prefix = "" } = {}) {
425
+ /** Prefer sync("so://bucket/prefix", "./dir"). */
426
+ async syncBucketToLocal(bucketName, localDir, { prefix = "", deleteExtra = false, skip = false } = {}) {
257
427
  this._c.requireAccessKey();
258
428
  const root = path.resolve(localDir);
259
429
  const prefixClean = String(prefix || "").replace(/^\/+|\/+$/g, "");
260
430
  const items = await this.listAllObjects(bucketName, { prefix: prefixClean, recursive: true });
431
+ let downloaded = 0;
432
+ let skippedCount = 0;
433
+ const remoteRels = new Set();
261
434
  for (const item of items) {
262
- let rel = item.key;
263
- if (prefixClean && item.key.startsWith(`${prefixClean}/`)) {
264
- rel = item.key.slice(prefixClean.length + 1);
265
- } else if (prefixClean && item.key === prefixClean) {
266
- rel = path.basename(item.key);
267
- }
435
+ const rel = this._syncRelativePath(item.key, prefixClean);
436
+ remoteRels.add(rel);
268
437
  const dest = path.join(root, rel);
438
+ if (
439
+ skip &&
440
+ fs.existsSync(dest) &&
441
+ fs.statSync(dest).isFile() &&
442
+ fs.statSync(dest).size === Number(item.size || 0)
443
+ ) {
444
+ skippedCount += 1;
445
+ continue;
446
+ }
269
447
  await this.download(bucketName, item.key, { destPath: dest });
448
+ downloaded += 1;
449
+ }
450
+ let deleted = 0;
451
+ if (deleteExtra && fs.existsSync(root)) {
452
+ const walk = (dir, base) => {
453
+ const out = [];
454
+ for (const name of fs.readdirSync(dir)) {
455
+ const full = path.join(dir, name);
456
+ const st = fs.statSync(full);
457
+ if (st.isDirectory()) out.push(...walk(full, base));
458
+ else out.push(path.relative(base, full).split(path.sep).join("/"));
459
+ }
460
+ return out;
461
+ };
462
+ for (const rel of walk(root, root)) {
463
+ if (remoteRels.has(rel)) continue;
464
+ fs.unlinkSync(path.join(root, rel));
465
+ deleted += 1;
466
+ }
270
467
  }
271
- return { downloaded: items.length };
468
+ return { downloaded, skipped: skippedCount, deleted };
272
469
  }
273
470
  }
274
471