@homecloud-platform/sdk 0.4.9 → 0.5.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.
package/README.md CHANGED
@@ -1,65 +1,69 @@
1
- # @homecloud-platform/sdk (Node.js)
2
-
3
- Part of the **homecloud-sdk** monorepo — Python at repo root; Node in `js/`.
4
-
5
- ## Install
6
-
7
- ```bash
8
- npm install @homecloud-platform/sdk
9
- ```
10
-
11
- From a local checkout (lab / layers):
12
-
13
- ```json
14
- {
15
- "dependencies": {
16
- "@homecloud-platform/sdk": "file:../../../homecloud-sdk/js"
17
- }
18
- }
19
- ```
20
-
21
- ## Usage
22
-
23
- ```js
24
- const { HomeCloud } = require("@homecloud-platform/sdk");
25
-
26
- exports.handler = async (event, context) => {
27
- const client = HomeCloud.fromSts(context.sts.archive, {
28
- accountId: context.account_id,
29
- });
30
- await client.so.putJson("my-bucket", "proof/ok.json", { ok: true });
31
- return { ok: true };
32
- };
33
- ```
34
-
35
- ## Unified release (Python + Node)
36
-
37
- One tag publishes **both** registries with the **same** version:
38
-
39
- ```bash
40
- ./scripts/bump-version.sh 0.5.0
41
- git add pyproject.toml js/package.json
42
- git commit -m "Release 0.5.0"
43
- git push origin HEAD
44
- git tag v0.5.0
45
- git push origin v0.5.0
46
- ```
47
-
48
- | Registry | Package | Trigger |
49
- |----------|---------|---------|
50
- | PyPI | `homecloud-sdk` | tag `v0.5.0` |
51
- | npm | `@homecloud-platform/sdk` | same tag `v0.5.0` |
52
-
53
- Workflows sync the version from the tag before publish (Trusted Publishing / OIDC).
54
-
55
- Optional npm-only hotfix (no PyPI): `js-v0.5.1`.
56
-
57
- ### One-time npm setup
58
-
59
- 1. Org **homecloud-platform** on npm.
60
- 2. First publish once locally: `cd js && npm publish --access public`
61
- 3. Package **Trusted Publisher** → GitHub Actions:
62
- - Org `HomeCloudLab`, repo `homecloud-sdk`
63
- - Workflow: `publish-npm.yml`
64
- - Environment: `npm`
65
- 4. GitHubEnvironmentscreate **`npm`** (and **`pypi`** already for Python).
1
+ # @homecloud-platform/sdk (Node.js)
2
+
3
+ Part of the **homecloud-sdk** monorepo — Python at repo root; Node in `js/`.
4
+
5
+ **Expectation:** Node supports the **same product surface** as Python (`homecloud-sdk`), released under the **same** `v*` version. See [PARITY.md](./PARITY.md).
6
+
7
+ Shipped as of **0.5.0**: SO / MQ / Secrets / Mail / Functions / Apps / Accounts / Queues, typed errors, `fromEnv` / `fromProfile` / `fromCredentials` / `fromSts` / `fromFunctionContext`, console login helpers.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install @homecloud-platform/sdk
13
+ ```
14
+
15
+ From a local checkout (lab / layers):
16
+
17
+ ```json
18
+ {
19
+ "dependencies": {
20
+ "@homecloud-platform/sdk": "file:../../../homecloud-sdk/js"
21
+ }
22
+ }
23
+ ```
24
+
25
+ ## Usage (MVP — STS + SO)
26
+
27
+ ```js
28
+ const { HomeCloud } = require("@homecloud-platform/sdk");
29
+
30
+ exports.handler = async (event, context) => {
31
+ const client = HomeCloud.fromSts(context.sts.archive, {
32
+ accountId: context.account_id,
33
+ });
34
+ await client.so.putJson("my-bucket", "proof/ok.json", { ok: true });
35
+ return { ok: true };
36
+ };
37
+ ```
38
+
39
+ ## Unified release (Python + Node)
40
+
41
+ One tag publishes **both** registries with the **same** version:
42
+
43
+ ```bash
44
+ ./scripts/bump-version.sh 0.5.0
45
+ git add pyproject.toml js/package.json
46
+ git commit -m "Release 0.5.0"
47
+ git push origin HEAD
48
+ git tag v0.5.0
49
+ git push origin v0.5.0
50
+ ```
51
+
52
+ | Registry | Package | Trigger |
53
+ |----------|---------|---------|
54
+ | PyPI | `homecloud-sdk` | tag `v0.5.0` |
55
+ | npm | `@homecloud-platform/sdk` | same tag `v0.5.0` |
56
+
57
+ Workflows sync the version from the tag before publish (Trusted Publishing / OIDC).
58
+
59
+ Optional npm-only hotfix (no PyPI): `js-v0.5.1`.
60
+
61
+ ### One-time npm setup
62
+
63
+ 1. Org **homecloud-platform** on npm.
64
+ 2. First publish once locally: `cd js && npm publish --access public`
65
+ 3. Package**Trusted Publisher** GitHub Actions:
66
+ - Org `HomeCloudLab`, repo `homecloud-sdk`
67
+ - Workflow: `publish-npm.yml`
68
+ - Environment: `npm`
69
+ 4. GitHub → Environments → create **`npm`** (and **`pypi`** already for Python).
package/package.json CHANGED
@@ -1,30 +1,30 @@
1
- {
2
- "name": "@homecloud-platform/sdk",
3
- "version": "0.4.9",
4
- "description": "HomeCloud Functions SDK for Node.js (ADR-033 / ADR-025e)",
5
- "main": "src/index.js",
6
- "types": "src/index.d.ts",
7
- "engines": {
8
- "node": ">=20"
9
- },
10
- "files": [
11
- "src"
12
- ],
13
- "license": "MIT",
14
- "repository": {
15
- "type": "git",
16
- "url": "git+https://github.com/HomeCloudLab/homecloud-sdk.git",
17
- "directory": "js"
18
- },
19
- "homepage": "https://github.com/HomeCloudLab/homecloud-sdk/tree/master/js",
20
- "bugs": {
21
- "url": "https://github.com/HomeCloudLab/homecloud-sdk/issues"
22
- },
23
- "publishConfig": {
24
- "access": "public",
25
- "registry": "https://registry.npmjs.org/"
26
- },
27
- "scripts": {
28
- "test": "node --test ./test/signing.test.js"
29
- }
30
- }
1
+ {
2
+ "name": "@homecloud-platform/sdk",
3
+ "version": "0.5.1",
4
+ "description": "HomeCloud Functions SDK for Node.js (ADR-033 / ADR-025e)",
5
+ "main": "src/index.js",
6
+ "types": "src/index.d.ts",
7
+ "engines": {
8
+ "node": ">=20"
9
+ },
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/HomeCloudLab/homecloud-sdk.git",
17
+ "directory": "js"
18
+ },
19
+ "homepage": "https://github.com/HomeCloudLab/homecloud-sdk/tree/master/js",
20
+ "bugs": {
21
+ "url": "https://github.com/HomeCloudLab/homecloud-sdk/issues"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public",
25
+ "registry": "https://registry.npmjs.org/"
26
+ },
27
+ "scripts": {
28
+ "test": "node --test ./test/signing.test.js"
29
+ }
30
+ }
package/src/client.js ADDED
@@ -0,0 +1,458 @@
1
+ "use strict";
2
+
3
+ const { URL } = require("url");
4
+ const {
5
+ DEFAULT_APEX,
6
+ WHOAMI_PATH,
7
+ WHOAMI_ACCOUNT_SENTINEL,
8
+ soUrl,
9
+ mqUrl,
10
+ secretsUrl,
11
+ mailApiUrl,
12
+ consoleUrl,
13
+ functionUrl,
14
+ envFirst,
15
+ } = require("./defaults");
16
+ const { signRequestHeaders } = require("./signing");
17
+ const {
18
+ HomeCloudError,
19
+ NotConfiguredError,
20
+ NotLoggedInError,
21
+ errorFromStatus,
22
+ } = require("./errors");
23
+ const { resolveProfile, loadSession, saveSession } = require("./credentials");
24
+ const { SoAPI } = require("./so");
25
+ const { MqAPI } = require("./mq");
26
+ const { SecretsAPI } = require("./secrets");
27
+ const { MailAPI } = require("./mail");
28
+ const { AccountsAPI, AppsAPI, QueuesAPI, FunctionsAPI } = require("./management");
29
+
30
+ function sleep(ms) {
31
+ return new Promise((r) => setTimeout(r, ms));
32
+ }
33
+
34
+ class HomeCloud {
35
+ constructor(opts = {}) {
36
+ this.accessKeyId = opts.accessKeyId || opts.access_key_id || null;
37
+ this.secretAccessKey = opts.secretAccessKey || opts.secret_access_key || null;
38
+ this.accountId = opts.accountId || opts.account_id || null;
39
+ this.apex = opts.apex || DEFAULT_APEX;
40
+ this.sessionToken = opts.sessionToken || opts.session_token || null;
41
+ this.accessToken = opts.accessToken || opts.access_token || null;
42
+ this.dataPlaneBases = Object.assign({}, opts.dataPlaneBases || opts.data_plane_bases || {});
43
+ this.consoleBaseUrl = opts.consoleBaseUrl || opts.console_base_url || null;
44
+ this.profileName = opts.profileName || opts.profile || null;
45
+ this.timeoutMs = opts.timeoutMs || 30000;
46
+
47
+ this.so = new SoAPI(this);
48
+ this.storage = this.so;
49
+ this.mq = new MqAPI(this);
50
+ this.secrets = new SecretsAPI(this);
51
+ this.mail = new MailAPI(this);
52
+ this.accounts = new AccountsAPI(this);
53
+ this.apps = new AppsAPI(this);
54
+ this.queues = new QueuesAPI(this);
55
+ this.functions = new FunctionsAPI(this);
56
+ }
57
+
58
+ static fromEnv(opts = {}) {
59
+ return HomeCloud.fromProfile(opts.profile || null, opts);
60
+ }
61
+
62
+ static fromProfile(profile, opts = {}) {
63
+ const resolved = resolveProfile(profile);
64
+ return new HomeCloud({
65
+ ...opts,
66
+ profileName: resolved.profileName,
67
+ accessKeyId: opts.accessKeyId || resolved.accessKeyId,
68
+ secretAccessKey: opts.secretAccessKey || resolved.secretAccessKey,
69
+ accountId: opts.accountId || resolved.accountId,
70
+ apex: opts.apex || resolved.apex,
71
+ accessToken: opts.accessToken || resolved.accessToken,
72
+ });
73
+ }
74
+
75
+ static fromCredentials(accessKeyId, secretAccessKey, opts = {}) {
76
+ return new HomeCloud({
77
+ ...opts,
78
+ accessKeyId,
79
+ secretAccessKey,
80
+ accountId: opts.accountId,
81
+ apex: opts.apex,
82
+ sessionToken: opts.sessionToken,
83
+ });
84
+ }
85
+
86
+ static fromSts(sts, { accountId, apex } = {}) {
87
+ const aid = accountId || process.env.HC_ACCOUNT_ID || envFirst("HOMECLOUD_ACCOUNT_ID") || "";
88
+ let base = String(sts.base_url || sts.mail_base_url || "").replace(/\/$/, "");
89
+ const resourceType = String(sts.resource_type || "").trim().toLowerCase();
90
+ let resolvedApex = apex || envFirst("HOMECLOUD_APEX", "HC_APEX") || DEFAULT_APEX;
91
+ const dataPlaneBases = {};
92
+ if (base) {
93
+ let host = "";
94
+ try {
95
+ host = new URL(base).hostname || "";
96
+ } catch (_) {
97
+ host = "";
98
+ }
99
+ if (resourceType === "mail") {
100
+ if (host.startsWith("console.") || base.includes("/api/v1")) {
101
+ if (host.startsWith("console.")) resolvedApex = resolvedApex || host.slice("console.".length);
102
+ dataPlaneBases.mail = mailApiUrl(resolvedApex).replace(/\/$/, "");
103
+ } else {
104
+ dataPlaneBases.mail = base;
105
+ if (host.startsWith("mailapi.")) resolvedApex = resolvedApex || host.slice("mailapi.".length);
106
+ }
107
+ } else if (["so", "mq", "secrets"].includes(resourceType)) {
108
+ dataPlaneBases[resourceType] = base;
109
+ const prefix = `${resourceType}.`;
110
+ if (host.startsWith(prefix)) resolvedApex = resolvedApex || host.slice(prefix.length);
111
+ }
112
+ } else if (resourceType === "mail") {
113
+ dataPlaneBases.mail = mailApiUrl(resolvedApex).replace(/\/$/, "");
114
+ }
115
+ return new HomeCloud({
116
+ accessKeyId: String(sts.access_key_id),
117
+ secretAccessKey: String(sts.secret_access_key),
118
+ accountId: aid,
119
+ apex: resolvedApex || DEFAULT_APEX,
120
+ sessionToken: sts.session_token ? String(sts.session_token) : null,
121
+ dataPlaneBases,
122
+ });
123
+ }
124
+
125
+ static fromFunctionContext(context, { binding }) {
126
+ const ctx = context || {};
127
+ let stsMap = Object.assign({}, ctx.sts || {});
128
+ if (!Object.keys(stsMap).length && process.env.HC_STS_JSON) {
129
+ try {
130
+ stsMap = JSON.parse(process.env.HC_STS_JSON);
131
+ } catch (_) {
132
+ stsMap = {};
133
+ }
134
+ }
135
+ const entry = stsMap[binding];
136
+ if (!entry || !entry.access_key_id) {
137
+ throw new HomeCloudError(`STS binding '${binding}' not found in context.sts`);
138
+ }
139
+ return HomeCloud.fromSts(entry, {
140
+ accountId: ctx.account_id || ctx.accountId || process.env.HC_ACCOUNT_ID,
141
+ });
142
+ }
143
+
144
+ requireAccessKey() {
145
+ if (!this.accessKeyId || !this.secretAccessKey) {
146
+ throw new NotConfiguredError(
147
+ "Access Key not configured. Run: homecloud configure, or set HOMECLOUD_ACCESS_KEY_ID"
148
+ );
149
+ }
150
+ }
151
+
152
+ requireConsole() {
153
+ if (!this.accessToken) throw new NotLoggedInError();
154
+ }
155
+
156
+ baseUrl(service) {
157
+ if (this.dataPlaneBases[service]) return this.dataPlaneBases[service].replace(/\/$/, "");
158
+ if (service === "so") return soUrl(this.apex);
159
+ if (service === "mq") return mqUrl(this.apex);
160
+ if (service === "secrets") return secretsUrl(this.apex);
161
+ if (service === "mail") return mailApiUrl(this.apex);
162
+ throw new HomeCloudError(`Unknown data-plane service: ${service}`);
163
+ }
164
+
165
+ async ensureAccountId() {
166
+ if (this.accountId) return this.accountId;
167
+ this.requireAccessKey();
168
+ const headers = signRequestHeaders({
169
+ accessKeyId: this.accessKeyId,
170
+ secret: this.secretAccessKey,
171
+ method: "GET",
172
+ path: WHOAMI_PATH,
173
+ accountId: WHOAMI_ACCOUNT_SENTINEL,
174
+ sessionToken: this.sessionToken,
175
+ });
176
+ const url = `${soUrl(this.apex).replace(/\/$/, "")}${WHOAMI_PATH}`;
177
+ const res = await fetch(url, { method: "GET", headers });
178
+ const data = await res.json().catch(() => ({}));
179
+ if (!res.ok) throw errorFromStatus(res.status, { detail: data, url });
180
+ this.accountId = data.account_id;
181
+ if (!this.accountId) throw new HomeCloudError("whoami did not return account_id");
182
+ return this.accountId;
183
+ }
184
+
185
+ async dataPlaneRequest(
186
+ service,
187
+ method,
188
+ reqPath,
189
+ { json, formData, body, headers, params, urlPath, signPath } = {}
190
+ ) {
191
+ await this.ensureAccountId();
192
+ this.requireAccessKey();
193
+ const base = this.baseUrl(service);
194
+ let pathForUrl = urlPath || reqPath;
195
+ let pathForSign = signPath || reqPath.split("?")[0];
196
+ if (params && Object.keys(params).length) {
197
+ const u = new URL(pathForUrl.startsWith("http") ? pathForUrl : `${base}${pathForUrl}`);
198
+ for (const [k, v] of Object.entries(params)) {
199
+ if (v === undefined || v === null) continue;
200
+ u.searchParams.set(k, String(v));
201
+ }
202
+ pathForUrl = u.pathname + u.search;
203
+ }
204
+ const url = new URL(pathForUrl.startsWith("http") ? pathForUrl : `${base}${pathForUrl}`);
205
+ const auth = signRequestHeaders({
206
+ accessKeyId: this.accessKeyId,
207
+ secret: this.secretAccessKey,
208
+ method,
209
+ path: pathForSign || url.pathname,
210
+ accountId: this.accountId,
211
+ sessionToken: this.sessionToken,
212
+ });
213
+ const init = {
214
+ method,
215
+ headers: { ...auth, ...(headers || {}) },
216
+ signal: AbortSignal.timeout(this.timeoutMs),
217
+ };
218
+ if (formData) init.body = formData;
219
+ else if (json !== undefined) {
220
+ init.headers["Content-Type"] = "application/json";
221
+ init.body = JSON.stringify(json);
222
+ } else if (body !== undefined) init.body = body;
223
+
224
+ let lastErr;
225
+ for (let attempt = 0; attempt < 3; attempt++) {
226
+ const res = await fetch(url, init);
227
+ if ([502, 503, 504].includes(res.status) && attempt < 2) {
228
+ await sleep(500 * (attempt + 1));
229
+ continue;
230
+ }
231
+ if (method === "DELETE" && res.status === 204) return null;
232
+ const text = await res.text();
233
+ let data = null;
234
+ if (text) {
235
+ try {
236
+ data = JSON.parse(text);
237
+ } catch (_) {
238
+ data = { raw: text };
239
+ }
240
+ }
241
+ if (!res.ok) {
242
+ lastErr = errorFromStatus(res.status, {
243
+ detail: data && (data.detail !== undefined ? data.detail : data),
244
+ url: String(url),
245
+ });
246
+ throw lastErr;
247
+ }
248
+ return data;
249
+ }
250
+ throw lastErr || new HomeCloudError("Request failed");
251
+ }
252
+
253
+ async dataPlaneRequestBytes(service, method, reqPath, { urlPath, signPath, params } = {}) {
254
+ await this.ensureAccountId();
255
+ this.requireAccessKey();
256
+ const base = this.baseUrl(service);
257
+ let pathForUrl = urlPath || reqPath;
258
+ let pathForSign = signPath || reqPath.split("?")[0];
259
+ if (params && Object.keys(params).length) {
260
+ const u = new URL(`${base}${pathForUrl}`);
261
+ for (const [k, v] of Object.entries(params)) u.searchParams.set(k, String(v));
262
+ pathForUrl = u.pathname + u.search;
263
+ }
264
+ const url = new URL(pathForUrl.startsWith("http") ? pathForUrl : `${base}${pathForUrl}`);
265
+ const auth = signRequestHeaders({
266
+ accessKeyId: this.accessKeyId,
267
+ secret: this.secretAccessKey,
268
+ method,
269
+ path: pathForSign || url.pathname,
270
+ accountId: this.accountId,
271
+ sessionToken: this.sessionToken,
272
+ });
273
+ const res = await fetch(url, { method, headers: auth, signal: AbortSignal.timeout(this.timeoutMs) });
274
+ if (!res.ok) {
275
+ const text = await res.text();
276
+ let data = null;
277
+ try {
278
+ data = JSON.parse(text);
279
+ } catch (_) {
280
+ data = text;
281
+ }
282
+ throw errorFromStatus(res.status, { detail: data, url: String(url) });
283
+ }
284
+ return Buffer.from(await res.arrayBuffer());
285
+ }
286
+
287
+ async consoleRequest(method, pathSeg, { json, params, requireAuth = true } = {}) {
288
+ if (requireAuth) this.requireConsole();
289
+ const base = (this.consoleBaseUrl || consoleUrl(this.apex)).replace(/\/$/, "");
290
+ const url = new URL(`${base}/${String(pathSeg).replace(/^\/+/, "")}`);
291
+ if (params) {
292
+ for (const [k, v] of Object.entries(params)) {
293
+ if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
294
+ }
295
+ }
296
+ const headers = {};
297
+ if (requireAuth && this.accessToken) headers.Authorization = `Bearer ${this.accessToken}`;
298
+ const init = { method, headers, signal: AbortSignal.timeout(this.timeoutMs) };
299
+ if (json !== undefined) {
300
+ headers["Content-Type"] = "application/json";
301
+ init.body = JSON.stringify(json);
302
+ }
303
+ const res = await fetch(url, init);
304
+ if (method === "DELETE" && res.status === 204) return null;
305
+ const text = await res.text();
306
+ let data = null;
307
+ if (text) {
308
+ try {
309
+ data = JSON.parse(text);
310
+ } catch (_) {
311
+ data = { raw: text };
312
+ }
313
+ }
314
+ if (!res.ok) {
315
+ throw errorFromStatus(res.status, {
316
+ detail: data && (data.detail !== undefined ? data.detail : data),
317
+ url: String(url),
318
+ });
319
+ }
320
+ return data;
321
+ }
322
+
323
+ async consoleRequestBytes(method, pathSeg) {
324
+ this.requireConsole();
325
+ const base = (this.consoleBaseUrl || consoleUrl(this.apex)).replace(/\/$/, "");
326
+ const url = `${base}/${String(pathSeg).replace(/^\/+/, "")}`;
327
+ const res = await fetch(url, {
328
+ method,
329
+ headers: { Authorization: `Bearer ${this.accessToken}` },
330
+ signal: AbortSignal.timeout(this.timeoutMs),
331
+ });
332
+ if (!res.ok) {
333
+ const text = await res.text();
334
+ let data = null;
335
+ try {
336
+ data = JSON.parse(text);
337
+ } catch (_) {
338
+ data = text;
339
+ }
340
+ throw errorFromStatus(res.status, { detail: data, url });
341
+ }
342
+ return Buffer.from(await res.arrayBuffer());
343
+ }
344
+
345
+ async functionUrlRequest(functionName, payload) {
346
+ this.requireAccessKey();
347
+ await this.ensureAccountId();
348
+ const pathSign = "/";
349
+ const headers = signRequestHeaders({
350
+ accessKeyId: this.accessKeyId,
351
+ secret: this.secretAccessKey,
352
+ method: "POST",
353
+ path: pathSign,
354
+ accountId: this.accountId,
355
+ sessionToken: this.sessionToken,
356
+ });
357
+ headers["Content-Type"] = "application/json";
358
+ const url = `${functionUrl(functionName, this.apex).replace(/\/$/, "")}/`;
359
+ const res = await fetch(url, {
360
+ method: "POST",
361
+ headers,
362
+ body: JSON.stringify(payload || {}),
363
+ signal: AbortSignal.timeout(this.timeoutMs),
364
+ });
365
+ const text = await res.text();
366
+ let data = null;
367
+ if (text) {
368
+ try {
369
+ data = JSON.parse(text);
370
+ } catch (_) {
371
+ data = { raw: text };
372
+ }
373
+ }
374
+ if (!res.ok) throw errorFromStatus(res.status, { detail: data, url });
375
+ return data;
376
+ }
377
+
378
+ async login(username, password, { mfaCode } = {}) {
379
+ const body = { username, password };
380
+ if (mfaCode) body.mfa_code = mfaCode;
381
+ const data = await this.consoleRequest("POST", "auth/login", {
382
+ json: body,
383
+ requireAuth: false,
384
+ });
385
+ const token = data.access_token;
386
+ if (!token) throw new HomeCloudError("Login failed");
387
+ this.accessToken = String(token);
388
+ this._persistAccessToken();
389
+ }
390
+
391
+ async loginBrowser({ openBrowser = true, onWaiting, mfaToken } = {}) {
392
+ const startBody = mfaToken ? { mfa_token: mfaToken } : {};
393
+ const start = await this.consoleRequest("POST", "auth/cli/session", {
394
+ json: startBody,
395
+ requireAuth: false,
396
+ });
397
+ const sessionId = start.session_id;
398
+ const verificationUri = start.verification_uri;
399
+ if (!sessionId || !verificationUri) throw new HomeCloudError("Failed to start browser login session");
400
+ const expiresIn = Number(start.expires_in || 600);
401
+ const interval = Math.max(1, Number(start.interval || 2));
402
+ const deadline = Date.now() + expiresIn * 1000;
403
+ if (openBrowser) {
404
+ try {
405
+ const { exec } = require("child_process");
406
+ const cmd =
407
+ process.platform === "win32"
408
+ ? `start "" "${verificationUri}"`
409
+ : process.platform === "darwin"
410
+ ? `open "${verificationUri}"`
411
+ : `xdg-open "${verificationUri}"`;
412
+ exec(cmd);
413
+ } catch (_) {}
414
+ }
415
+ if (onWaiting) onWaiting(String(verificationUri));
416
+ while (Date.now() < deadline) {
417
+ const poll = await this.consoleRequest("GET", `auth/cli/session/${sessionId}`, {
418
+ requireAuth: false,
419
+ });
420
+ const status = String(poll.status || "");
421
+ if (status === "complete") {
422
+ if (!poll.access_token) throw new HomeCloudError("Browser login completed without access token");
423
+ this.accessToken = String(poll.access_token);
424
+ this._persistAccessToken();
425
+ return;
426
+ }
427
+ if (status === "expired") throw new HomeCloudError("Browser login session expired");
428
+ await sleep(interval * 1000);
429
+ }
430
+ throw new HomeCloudError("Browser login timed out");
431
+ }
432
+
433
+ _persistAccessToken() {
434
+ if (!this.profileName) return;
435
+ const session = loadSession();
436
+ session.profiles[this.profileName] = {
437
+ ...(session.profiles[this.profileName] || {}),
438
+ access_token: this.accessToken,
439
+ active_account_id: this.accountId || undefined,
440
+ };
441
+ saveSession(session);
442
+ }
443
+
444
+ async accountIdResolved() {
445
+ return this.ensureAccountId();
446
+ }
447
+
448
+ /** Sync helper matching Python client.account_id(). */
449
+ account_id() {
450
+ if (this.accountId) return this.accountId;
451
+ throw new HomeCloudError("accountId not set — call await client.ensureAccountId() first");
452
+ }
453
+ }
454
+
455
+ /** Node is async-native; alias kept for Python parity naming. */
456
+ const AsyncHomeCloud = HomeCloud;
457
+
458
+ module.exports = { HomeCloud, AsyncHomeCloud };