@seliseblocks/cli-os 0.1.2 → 0.1.4

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 (46) hide show
  1. package/AI_USAGE_GUIDE.md +18 -38
  2. package/README.md +13 -36
  3. package/dist/commands/auth/status.js +16 -55
  4. package/dist/commands/deselect.d.ts +1 -0
  5. package/dist/commands/deselect.js +31 -0
  6. package/dist/commands/doctor.js +3 -4
  7. package/dist/commands/init.js +4 -2
  8. package/dist/commands/login.d.ts +0 -1
  9. package/dist/commands/login.js +15 -55
  10. package/dist/commands/new/web.js +1 -1
  11. package/dist/commands/release/builds/list.js +4 -2
  12. package/dist/commands/release/deploy.js +5 -3
  13. package/dist/commands/release/status.js +4 -2
  14. package/dist/index.js +32 -60
  15. package/dist/lib/auth.d.ts +4 -11
  16. package/dist/lib/auth.js +43 -18
  17. package/dist/lib/config.d.ts +1 -2
  18. package/dist/lib/config.js +45 -13
  19. package/dist/lib/open-browser.js +26 -3
  20. package/dist/lib/secret-store.d.ts +0 -1
  21. package/dist/lib/secret-store.js +1 -4
  22. package/dist/lib/token-store.d.ts +0 -1
  23. package/dist/lib/token-store.js +1 -5
  24. package/dist/lib/token.d.ts +0 -1
  25. package/dist/lib/token.js +0 -4
  26. package/dist/lib/workspace.d.ts +1 -0
  27. package/dist/lib/workspace.js +17 -0
  28. package/package.json +1 -1
  29. package/dist/commands/auth/add.d.ts +0 -1
  30. package/dist/commands/auth/add.js +0 -51
  31. package/dist/commands/auth/list.d.ts +0 -1
  32. package/dist/commands/auth/list.js +0 -33
  33. package/dist/commands/auth/repair.d.ts +0 -1
  34. package/dist/commands/auth/repair.js +0 -39
  35. package/dist/commands/auth/show.d.ts +0 -1
  36. package/dist/commands/auth/show.js +0 -23
  37. package/dist/commands/auth/use.d.ts +0 -1
  38. package/dist/commands/auth/use.js +0 -15
  39. package/dist/commands/login-device.d.ts +0 -1
  40. package/dist/commands/login-device.js +0 -6
  41. package/dist/lib/login-server.d.ts +0 -1
  42. package/dist/lib/login-server.js +0 -43
  43. package/dist/lib/pkce.d.ts +0 -4
  44. package/dist/lib/pkce.js +0 -9
  45. package/dist/lib/prompt.d.ts +0 -2
  46. package/dist/lib/prompt.js +0 -36
package/dist/index.js CHANGED
@@ -1,11 +1,7 @@
1
- import { authAdd } from "./commands/auth/add.js";
2
- import { authList } from "./commands/auth/list.js";
3
1
  import { authRefresh } from "./commands/auth/refresh.js";
4
- import { authRepair } from "./commands/auth/repair.js";
5
2
  import { authRemove } from "./commands/auth/remove.js";
6
- import { authShow } from "./commands/auth/show.js";
7
3
  import { authStatus } from "./commands/auth/status.js";
8
- import { authUse } from "./commands/auth/use.js";
4
+ import { deselectProject } from "./commands/deselect.js";
9
5
  import { doctor } from "./commands/doctor.js";
10
6
  import { dataReload } from "./commands/data/reload.js";
11
7
  import { dataValidate } from "./commands/data/validate.js";
@@ -20,7 +16,6 @@ import { localizationPull } from "./commands/localization/pull.js";
20
16
  import { localizationPush } from "./commands/localization/push.js";
21
17
  import { localizationValidate } from "./commands/localization/validate.js";
22
18
  import { login } from "./commands/login.js";
23
- import { loginDevice } from "./commands/login-device.js";
24
19
  import { logout } from "./commands/logout.js";
25
20
  import { newWeb } from "./commands/new/web.js";
26
21
  import { createProject } from "./commands/projects/create.js";
@@ -40,18 +35,6 @@ try {
40
35
  else if (!command || command === "help" || command === "--help" || command === "-h") {
41
36
  printHelp();
42
37
  }
43
- else if (command === "auth:add" || (command === "auth" && subcommand === "add")) {
44
- await authAdd(command === "auth" ? rest : [subcommand, ...rest].filter(Boolean));
45
- }
46
- else if (command === "auth:list" || (command === "auth" && subcommand === "list")) {
47
- await authList(command === "auth" ? rest : [subcommand, ...rest].filter(Boolean));
48
- }
49
- else if (command === "auth:use" || (command === "auth" && subcommand === "use")) {
50
- await authUse(command === "auth" ? rest : [subcommand, ...rest].filter(Boolean));
51
- }
52
- else if (command === "auth:show" || (command === "auth" && subcommand === "show")) {
53
- await authShow(command === "auth" ? rest : [subcommand, ...rest].filter(Boolean));
54
- }
55
38
  else if (command === "auth:remove" || (command === "auth" && subcommand === "remove")) {
56
39
  await authRemove(command === "auth" ? rest : [subcommand, ...rest].filter(Boolean));
57
40
  }
@@ -61,27 +44,18 @@ try {
61
44
  else if (command === "auth:refresh") {
62
45
  await authRefresh([subcommand, ...rest].filter(Boolean));
63
46
  }
64
- else if (command === "auth:repair") {
65
- await authRepair([subcommand, ...rest].filter(Boolean));
66
- }
67
47
  else if (command === "auth" && subcommand === "status") {
68
48
  await authStatus(rest);
69
49
  }
70
50
  else if (command === "auth" && subcommand === "refresh") {
71
51
  await authRefresh(rest);
72
52
  }
73
- else if (command === "auth" && subcommand === "repair") {
74
- await authRepair(rest);
75
- }
76
53
  else if (command === "doctor") {
77
54
  await doctor([subcommand, ...rest].filter(Boolean));
78
55
  }
79
56
  else if (command === "init") {
80
57
  await init();
81
58
  }
82
- else if (command === "login:device") {
83
- await loginDevice([subcommand, ...rest].filter(Boolean));
84
- }
85
59
  else if (command === "login") {
86
60
  await login([subcommand, ...rest].filter(Boolean));
87
61
  }
@@ -106,6 +80,9 @@ try {
106
80
  else if (command === "use") {
107
81
  await useProject([subcommand, ...rest].filter(Boolean));
108
82
  }
83
+ else if (command === "deselect") {
84
+ await deselectProject([subcommand, ...rest].filter(Boolean));
85
+ }
109
86
  else if (command === "new" && subcommand === "web") {
110
87
  await newWeb(rest);
111
88
  }
@@ -181,7 +158,7 @@ function toCliError(error) {
181
158
  return { code: "not_logged_in", message, nextStep: "blocks-os login" };
182
159
  }
183
160
  if (message.includes("token expired") || message.includes("refresh token") || message.includes("cannot decrypt")) {
184
- return { code: "auth_repair_required", message, nextStep: "blocks-os auth:repair --yes && blocks-os auth:add --client-id <id> --client-secret <secret> && blocks-os login" };
161
+ return { code: "auth_repair_required", message, nextStep: "blocks-os login" };
185
162
  }
186
163
  if (message.includes("No project selected")) {
187
164
  return { code: "project_not_selected", message, nextStep: "blocks-os use <tenantId>" };
@@ -216,41 +193,22 @@ Setup and health:
216
193
  file locations. Does not mutate cloud resources.
217
194
 
218
195
  Auth:
219
- blocks-os auth:add [account] --client-id <id> --client-secret <secret>
220
- Save OIDC client settings for the CLI. Defaults target production Blocks
221
- URLs, root tenant, scope, and localhost callback. Secret is stored separately.
222
-
223
196
  blocks-os login
224
- Browser Authorization Code + PKCE login. Opens IAM, captures the localhost
225
- callback, stores account access and refresh tokens, and auto-refreshes later.
226
-
227
- blocks-os login:device
228
- Device-code login for terminals or agents where a browser callback is not
229
- convenient. Uses the same saved account profile.
197
+ Device-code login. Prints a verification URL and user code, opens the
198
+ browser to the verification page when possible so you only need to click
199
+ approve, then polls until the device is authorized; stores account access
200
+ and refresh tokens and auto-refreshes later.
230
201
 
231
202
  blocks-os auth:status [--json]
232
- Show configured account, token freshness, refresh-token availability, and
233
- selected project without printing secrets.
203
+ Show only whether account/project access and refresh tokens are missing,
204
+ valid, expired, or available. Does not print account config values.
234
205
 
235
206
  blocks-os auth:refresh [--project] [--json]
236
207
  Force account token refresh, or project token refresh with --project.
237
208
 
238
- blocks-os auth:repair [account] [--dry-run] [--yes] [--json]
239
- Clear broken local secure-store auth state while keeping account profile
240
- config. Use after OS credential migration, stale DPAPI, or corrupted tokens.
241
-
242
- blocks-os auth:list [--json]
243
- List saved account profiles without showing secrets.
244
-
245
- blocks-os auth:show [account]
246
- Print one account profile with secret redacted as configured/missing.
247
-
248
- blocks-os auth:use <account>
249
- Switch the active named account profile.
250
-
251
209
  blocks-os auth:remove <account>
252
- Remove account config, cached tokens, and stored client secret for that
253
- account.
210
+ Clear cached tokens and stored local credentials for that account. The
211
+ packaged default OS account is restored from package defaults.
254
212
 
255
213
  blocks-os logout
256
214
  Revoke the current refresh token when possible and remove local session data.
@@ -272,6 +230,12 @@ Projects:
272
230
  Save the selected project tenant globally and in blocks.json when present.
273
231
  Does not call cloud APIs.
274
232
 
233
+ blocks-os deselect
234
+ Clear the selected project tenant (globally and in blocks.json) and drop
235
+ its cached impersonation token. Use this to recover when an impersonated
236
+ project token has expired or failed, then run 'blocks-os use <tenantId>'
237
+ again to reselect and re-impersonate.
238
+
275
239
  IAM:
276
240
  blocks-os iam:me [--json]
277
241
  Read the current user from IAM using the account token. This is the only IAM
@@ -321,23 +285,31 @@ Localization:
321
285
 
322
286
  Release:
323
287
  blocks-os release:deploy --repo-id <repoId> [--dry-run] [--yes] [--json]
324
- Trigger a manual Release build/deploy for a configured repository. Mutating;
325
- no artifact upload is performed by this CLI.
288
+ Trigger a manual Release build/deploy for a configured repository using an
289
+ impersonated project token. Mutating; no artifact upload is performed by
290
+ this CLI.
326
291
 
327
292
  blocks-os release:status <buildId> [--json]
328
- Read Release build status by build id. Read-only.
293
+ Read Release build status by build id using an impersonated project
294
+ token. Read-only.
329
295
 
330
296
  blocks-os release:builds:list --repo-id <repoId> [--json]
331
- List Release build details for a repository. Read-only.
297
+ List Release build details for a repository using an impersonated project
298
+ token. Read-only.
332
299
 
333
300
  blocks-os release:builds:get <buildId> [--json]
334
301
  Alias for release:status. Read-only.
335
302
 
336
303
  Scaffold:
337
- blocks-os new web <name> --x-blocks-key <tenantId> --app-domain <domain> --blocks-api-url <url> [--client-id <oidcClientId>] [--oidc-url <url>]
304
+ blocks-os new web <name> --x-blocks-key <tenantId> --app-domain <domain> [--client-id <oidcClientId>]
305
+ [--blocks-api-url <url>] [--oidc-url <url>]
338
306
  Create a Vite React starter app with a real browser Authorization Code +
339
307
  PKCE login flow against Blocks IAM (login page, /login/callback handler,
340
308
  route guards), plus Data schema listing, Release build lookup, environment
341
309
  config, and safe .gitignore defaults.
310
+ --blocks-api-url defaults to the OS API (os.seliseblocks.com) if omitted -
311
+ pass the runtime Data/IAM/Localization gateway URL explicitly (typically
312
+ https://api.seliseblocks.com) for the scaffolded app to work at runtime.
313
+ --oidc-url defaults to https://iam.seliseblocks.com.
342
314
  `);
343
315
  }
@@ -20,18 +20,11 @@ export type DeviceAuthorizationResponse = {
20
20
  verification_uri: string;
21
21
  verification_uri_complete?: string;
22
22
  };
23
- export declare function exchangeAuthorizationCode(args: {
24
- account: string;
25
- clientId: string;
26
- clientSecret?: string;
27
- code: string;
28
- codeVerifier?: string;
29
- oidcUrl: string;
30
- redirectUri: string;
31
- rootTenantId?: string;
32
- }): Promise<TokenResponse>;
23
+ export type DevicePollingOptions = {
24
+ onWait?: (seconds: number) => void;
25
+ };
33
26
  export declare function requestDeviceAuthorization(profile: AccountProfile): Promise<DeviceAuthorizationResponse>;
34
- export declare function pollDeviceToken(profile: AccountProfile, device: DeviceAuthorizationResponse): Promise<TokenResponse>;
27
+ export declare function pollDeviceToken(profile: AccountProfile, device: DeviceAuthorizationResponse, options?: DevicePollingOptions): Promise<TokenResponse>;
35
28
  export declare function getAccountSession(accountOverride?: string): Promise<AccountSession>;
36
29
  export declare function selectProject(tenantId: string): Promise<void>;
37
30
  export declare function getImpersonatedProjectSession(accountOverride?: string, tenantOverride?: string): Promise<ProjectSession>;
package/dist/lib/auth.js CHANGED
@@ -5,18 +5,7 @@ import { getClientSecret } from "./secret-store.js";
5
5
  import { CliActionableError } from "./errors.js";
6
6
  const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
7
7
  const FALLBACK_IMPERSONATION_CLIENT_ID = "57214b67-aa9c-4307-92ab-a25e35180fac";
8
- export async function exchangeAuthorizationCode(args) {
9
- const body = new URLSearchParams({
10
- client_id: args.clientId,
11
- code: args.code,
12
- grant_type: "authorization_code",
13
- redirect_uri: args.redirectUri
14
- });
15
- if (args.codeVerifier)
16
- body.set("code_verifier", args.codeVerifier);
17
- applyClientSecret(body, args.clientSecret);
18
- return postFormToken(args.oidcUrl, body, args.rootTenantId);
19
- }
8
+ const MAX_CONSECUTIVE_TRANSIENT_ERRORS = 3;
20
9
  export async function requestDeviceAuthorization(profile) {
21
10
  const rootTenantId = await resolveRootTenantForDevice(profile);
22
11
  if (!rootTenantId) {
@@ -45,34 +34,70 @@ export async function requestDeviceAuthorization(profile) {
45
34
  }
46
35
  return data;
47
36
  }
48
- export async function pollDeviceToken(profile, device) {
37
+ export async function pollDeviceToken(profile, device, options = {}) {
49
38
  const rootTenantId = await resolveRootTenantForDevice(profile);
50
39
  if (!rootTenantId) {
51
40
  throw new Error("Device token polling requires rootTenantId in the account profile.");
52
41
  }
53
42
  let intervalSeconds = Math.max(device.interval ?? 5, 1);
54
43
  const deadline = Date.now() + device.expires_in * 1000;
44
+ let consecutiveTransientErrors = 0;
55
45
  while (Date.now() < deadline) {
56
- await delay(intervalSeconds * 1000);
46
+ options.onWait?.(intervalSeconds);
47
+ await delay(Math.min(intervalSeconds * 1000, Math.max(deadline - Date.now(), 0)));
57
48
  const body = new URLSearchParams({
58
49
  client_id: profile.clientId,
59
50
  device_code: device.device_code,
60
51
  grant_type: DEVICE_GRANT
61
52
  });
62
53
  applyClientSecret(body, await getSecretForProfile(profile));
63
- const response = await postFormToken(profile.oidcUrl, body, rootTenantId, false);
64
- if (response.error === "authorization_pending")
54
+ let response;
55
+ try {
56
+ response = await postFormToken(profile.oidcUrl, body, rootTenantId, false);
57
+ }
58
+ catch (error) {
59
+ // The token endpoint itself never throws (postFormToken with
60
+ // throwOnOAuthError=false only returns error payloads) -- a thrown
61
+ // error here means fetch() failed before any response came back
62
+ // (DNS, connection reset, etc.). Treat it as transient and keep
63
+ // polling rather than aborting a multi-minute wait on one blip.
64
+ throwIfTooManyTransientErrors(++consecutiveTransientErrors, error);
65
65
  continue;
66
+ }
67
+ if (response.error === "authorization_pending") {
68
+ consecutiveTransientErrors = 0;
69
+ continue;
70
+ }
66
71
  if (response.error === "slow_down") {
72
+ consecutiveTransientErrors = 0;
67
73
  intervalSeconds += 5;
68
74
  continue;
69
75
  }
76
+ if (response.error === "token_request_failed") {
77
+ // Synthetic error from postFormToken for a non-JSON HTTP failure
78
+ // (e.g. a 502 from an upstream proxy) -- not a real OAuth rejection,
79
+ // so treat it the same as a network blip.
80
+ throwIfTooManyTransientErrors(++consecutiveTransientErrors, response.error_description ?? response.error);
81
+ continue;
82
+ }
83
+ if (response.error === "access_denied") {
84
+ throw new CliActionableError("Device authorization was denied.", "device_login_denied", "blocks-os login");
85
+ }
86
+ if (response.error === "expired_token") {
87
+ throw new CliActionableError("Device login expired before approval.", "device_login_expired", "blocks-os login");
88
+ }
70
89
  if (response.error) {
71
- throw new Error(response.error_description ?? response.error);
90
+ throw new CliActionableError(response.error_description ?? response.error, "device_login_failed", "blocks-os login");
72
91
  }
73
92
  return response;
74
93
  }
75
- throw new Error("Device login expired before approval.");
94
+ throw new CliActionableError("Device login expired before approval.", "device_login_expired", "blocks-os login");
95
+ }
96
+ function throwIfTooManyTransientErrors(count, cause) {
97
+ if (count <= MAX_CONSECUTIVE_TRANSIENT_ERRORS)
98
+ return;
99
+ const detail = cause instanceof Error ? cause.message : String(cause);
100
+ throw new CliActionableError(`Could not reach the identity provider while waiting for device approval (${detail}).`, "device_login_network_error", "Check your network connection and run 'blocks-os login' again.");
76
101
  }
77
102
  export async function getAccountSession(accountOverride) {
78
103
  let config = await readConfig();
@@ -4,7 +4,6 @@ export type AccountProfile = {
4
4
  createdAt: string;
5
5
  oidcUrl: string;
6
6
  osUrl: string;
7
- redirectUri: string;
8
7
  rootTenantId?: string;
9
8
  scope: string;
10
9
  updatedAt: string;
@@ -31,9 +30,9 @@ export type BlocksCliConfig = {
31
30
  };
32
31
  export declare function defaults(): {
33
32
  apiUrl: string;
33
+ osClientId: string;
34
34
  oidcUrl: string;
35
35
  osUrl: string;
36
- redirectUri: string;
37
36
  rootTenantId: string;
38
37
  scope: string;
39
38
  };
@@ -1,19 +1,20 @@
1
1
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { homedir, platform } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
- const DEFAULT_API_URL = "https://api.seliseblocks.com";
4
+ const BAD_GATEWAY_OS_URL = "https://api.seliseblocks.com/os/v4";
5
+ const DEFAULT_API_URL = "https://os.seliseblocks.com";
6
+ const DEFAULT_OS_CLIENT_ID = "4a633b13-1108-4fbf-84fd-b196c9dcdee2";
5
7
  const DEFAULT_OIDC_URL = "https://iam.seliseblocks.com";
6
8
  const DEFAULT_OS_URL = "https://os.seliseblocks.com";
7
- const BAD_GATEWAY_OS_URL = "https://api.seliseblocks.com/os/v4";
9
+ const DEFAULT_PROFILE_TIMESTAMP = "2026-01-01T00:00:00.000Z";
8
10
  const DEFAULT_ROOT_TENANT_ID = "d7e5554c758541db8a18694b64ef423d";
9
11
  const DEFAULT_SCOPE = "openid profile offline_access";
10
- const DEFAULT_REDIRECT_URI = "http://127.0.0.1:8976/callback";
11
12
  export function defaults() {
12
13
  return {
13
14
  apiUrl: DEFAULT_API_URL,
15
+ osClientId: DEFAULT_OS_CLIENT_ID,
14
16
  oidcUrl: DEFAULT_OIDC_URL,
15
17
  osUrl: DEFAULT_OS_URL,
16
- redirectUri: DEFAULT_REDIRECT_URI,
17
18
  rootTenantId: DEFAULT_ROOT_TENANT_ID,
18
19
  scope: DEFAULT_SCOPE
19
20
  };
@@ -40,9 +41,7 @@ export async function readConfig() {
40
41
  }
41
42
  catch (error) {
42
43
  if (error.code === "ENOENT") {
43
- return {
44
- accounts: {}
45
- };
44
+ return defaultConfig();
46
45
  }
47
46
  throw error;
48
47
  }
@@ -61,7 +60,7 @@ export function normalizeAccountName(account) {
61
60
  export function getActiveAccountName(config, override) {
62
61
  const account = normalizeAccountName(override ?? config.activeAccount);
63
62
  if (!config.accounts[account]) {
64
- throw new Error("OIDC account is not configured. Run 'blocks-os auth:add --client-id <id> --client-secret <secret>' first.");
63
+ throw new Error("OIDC account is not configured.");
65
64
  }
66
65
  return account;
67
66
  }
@@ -73,21 +72,54 @@ export function getAccountProfile(config, account) {
73
72
  };
74
73
  }
75
74
  function normalizeConfig(config) {
75
+ const env = defaults();
76
76
  const accounts = {};
77
77
  for (const [name, profile] of Object.entries(config.accounts ?? {})) {
78
- accounts[name] = {
78
+ accounts[name] = name === "default" ? defaultProfile(profile) : {
79
79
  ...profile,
80
- oidcUrl: profile.oidcUrl ?? DEFAULT_OIDC_URL,
81
- osUrl: !profile.osUrl || profile.osUrl === BAD_GATEWAY_OS_URL ? DEFAULT_OS_URL : profile.osUrl,
82
- rootTenantId: profile.rootTenantId ?? DEFAULT_ROOT_TENANT_ID
80
+ apiUrl: profile.apiUrl ?? env.apiUrl,
81
+ clientId: profile.clientId ?? env.osClientId,
82
+ createdAt: profile.createdAt ?? DEFAULT_PROFILE_TIMESTAMP,
83
+ oidcUrl: profile.oidcUrl ?? env.oidcUrl,
84
+ osUrl: !profile.osUrl || profile.osUrl === BAD_GATEWAY_OS_URL ? env.osUrl : profile.osUrl,
85
+ rootTenantId: profile.rootTenantId ?? env.rootTenantId,
86
+ scope: profile.scope ?? env.scope,
87
+ updatedAt: profile.updatedAt ?? DEFAULT_PROFILE_TIMESTAMP
88
+ };
89
+ }
90
+ if (Object.keys(accounts).length === 0) {
91
+ return {
92
+ ...defaultConfig(),
93
+ selectedProject: config.selectedProject
83
94
  };
84
95
  }
85
96
  return {
86
- activeAccount: config.activeAccount,
97
+ activeAccount: config.activeAccount ?? "default",
87
98
  accounts,
88
99
  selectedProject: config.selectedProject
89
100
  };
90
101
  }
102
+ function defaultConfig() {
103
+ return {
104
+ activeAccount: "default",
105
+ accounts: {
106
+ default: defaultProfile()
107
+ }
108
+ };
109
+ }
110
+ function defaultProfile(existing) {
111
+ const env = defaults();
112
+ return {
113
+ apiUrl: env.apiUrl,
114
+ clientId: env.osClientId,
115
+ createdAt: existing?.createdAt ?? DEFAULT_PROFILE_TIMESTAMP,
116
+ oidcUrl: env.oidcUrl,
117
+ osUrl: env.osUrl,
118
+ rootTenantId: env.rootTenantId,
119
+ scope: env.scope,
120
+ updatedAt: DEFAULT_PROFILE_TIMESTAMP
121
+ };
122
+ }
91
123
  function nonEmptyEnv(name) {
92
124
  const value = process.env[name]?.trim();
93
125
  return value || undefined;
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { platform } from "node:os";
3
+ const LAUNCH_GRACE_PERIOD_MS = 500;
3
4
  export async function openBrowser(url) {
4
5
  const os = platform();
5
6
  const command = os === "win32" ? "rundll32" : os === "darwin" ? "open" : "xdg-open";
@@ -10,10 +11,32 @@ export async function openBrowser(url) {
10
11
  stdio: "ignore",
11
12
  windowsHide: true
12
13
  });
13
- child.once("error", () => resolve(false));
14
- child.once("spawn", () => {
14
+ let settled = false;
15
+ const settle = (result) => {
16
+ if (settled)
17
+ return;
18
+ settled = true;
19
+ resolve(result);
20
+ };
21
+ child.once("error", () => settle(false));
22
+ // A launcher like xdg-open can spawn successfully and still exit
23
+ // non-zero almost immediately when no browser/display session is
24
+ // available (headless SSH, missing DISPLAY, etc.). Give it a short
25
+ // grace period to fail fast before treating the launch as a success
26
+ // and detaching the process.
27
+ const grace = setTimeout(() => {
15
28
  child.unref();
16
- resolve(true);
29
+ settle(true);
30
+ }, LAUNCH_GRACE_PERIOD_MS);
31
+ child.once("exit", (code) => {
32
+ clearTimeout(grace);
33
+ if (code === 0 || code === null) {
34
+ child.unref();
35
+ settle(true);
36
+ }
37
+ else {
38
+ settle(false);
39
+ }
17
40
  });
18
41
  });
19
42
  }
@@ -17,7 +17,6 @@ export declare function writeSecretStore(store: BlocksSecretStore): Promise<void
17
17
  export declare function setClientSecret(account: string, clientSecret: string): Promise<void>;
18
18
  export declare function getClientSecret(account: string): Promise<string | undefined>;
19
19
  export declare function removeAccountSecrets(account: string): Promise<void>;
20
- export declare function clearSecretStore(): Promise<void>;
21
20
  export declare function setSecretValue(key: string, value: string): Promise<void>;
22
21
  export declare function getSecretValue(key: string): Promise<string | undefined>;
23
22
  export declare function removeSecretValue(key: string): Promise<void>;
@@ -1,5 +1,5 @@
1
1
  import { execFile, spawn } from "node:child_process";
2
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { platform } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { promisify } from "node:util";
@@ -57,9 +57,6 @@ export async function removeAccountSecrets(account) {
57
57
  await removeSecretValue(`client-secret:${account}`);
58
58
  await removeFallbackSecret(account);
59
59
  }
60
- export async function clearSecretStore() {
61
- await rm(secretPath(), { force: true });
62
- }
63
60
  export async function setSecretValue(key, value) {
64
61
  const backend = await resolveBackend();
65
62
  if (backend === "macos-keychain") {
@@ -15,4 +15,3 @@ export declare function tokenStoreInfo(): Promise<{
15
15
  export declare function readTokenStore(): Promise<BlocksTokenStore>;
16
16
  export declare function writeTokenStore(store: BlocksTokenStore): Promise<void>;
17
17
  export declare function removeAccountTokens(account: string): Promise<void>;
18
- export declare function clearTokenStore(): Promise<void>;
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { configDir, configPath } from "./config.js";
4
- import { getSecretValue, removeSecretValue, secretStoreInfo, setSecretValue } from "./secret-store.js";
4
+ import { getSecretValue, secretStoreInfo, setSecretValue } from "./secret-store.js";
5
5
  const TOKEN_SECRET_KEY = "oauth-token-store";
6
6
  export function tokenPath() {
7
7
  return join(configDir(), "tokens.json");
@@ -56,10 +56,6 @@ export async function removeAccountTokens(account) {
56
56
  const { [account]: _, ...accounts } = store.accounts;
57
57
  await writeTokenStore({ accounts });
58
58
  }
59
- export async function clearTokenStore() {
60
- await removeSecretValue(TOKEN_SECRET_KEY);
61
- await rm(tokenPath(), { force: true });
62
- }
63
59
  function normalizeTokenStore(store) {
64
60
  return {
65
61
  accounts: store?.accounts ?? {}
@@ -13,7 +13,6 @@ export type TokenResponse = {
13
13
  scope?: string;
14
14
  token_type?: string;
15
15
  };
16
- export declare function randomState(): string;
17
16
  export declare function expiresAtFromToken(response: TokenResponse, accessToken: string): string | undefined;
18
17
  export declare function expiresAtFromJwtFirst(response: TokenResponse, accessToken: string): string | undefined;
19
18
  export declare function refreshExpiresAtFromToken(response: TokenResponse, refreshToken?: string): string | undefined;
package/dist/lib/token.js CHANGED
@@ -1,10 +1,6 @@
1
- import { randomBytes } from "node:crypto";
2
1
  import { decodeJwtPayload, tenantFromToken } from "./jwt.js";
3
2
  const EXPIRY_SKEW_MS = 60_000;
4
3
  const DEFAULT_REFRESH_TOKEN_LIFETIME_SECONDS = 30 * 60;
5
- export function randomState() {
6
- return randomBytes(24).toString("base64url");
7
- }
8
4
  export function expiresAtFromToken(response, accessToken) {
9
5
  if (typeof response.expires_in === "number" && response.expires_in > 0) {
10
6
  return new Date(Date.now() + response.expires_in * 1000).toISOString();
@@ -20,6 +20,7 @@ export declare function readWorkspaceConfig(): Promise<BlocksWorkspaceConfig>;
20
20
  export declare function writeWorkspaceConfig(config: BlocksWorkspaceConfig): Promise<void>;
21
21
  export declare function selectedProject(flags: Record<string, string | boolean>): Promise<string>;
22
22
  export declare function saveSelectedProject(tenantId: string): Promise<void>;
23
+ export declare function clearSelectedProject(): Promise<string | undefined>;
23
24
  export declare function pathsFromWorkspace(config: BlocksWorkspaceConfig): {
24
25
  dictionaries: string;
25
26
  releaseConfig: string;
@@ -47,6 +47,23 @@ export async function saveSelectedProject(tenantId) {
47
47
  });
48
48
  }
49
49
  }
50
+ export async function clearSelectedProject() {
51
+ const global = await readConfig();
52
+ const tenantId = global.selectedProject?.tenantId;
53
+ if (!tenantId)
54
+ return undefined;
55
+ const { selectedProject: _dropped, ...rest } = global;
56
+ await writeConfig(rest);
57
+ const local = await readWorkspaceConfig();
58
+ if (local.project?.tenantId) {
59
+ const { tenantId: _localTenantId, ...restProject } = local.project;
60
+ await writeWorkspaceConfig({
61
+ ...local,
62
+ project: Object.keys(restProject).length > 0 ? restProject : undefined
63
+ });
64
+ }
65
+ return tenantId;
66
+ }
50
67
  export function pathsFromWorkspace(config) {
51
68
  return {
52
69
  dictionaries: config.localization?.dictionaries ?? "blocks/localization",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seliseblocks/cli-os",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "CLI for SELISE Blocks OS project setup and configuration.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1 +0,0 @@
1
- export declare function authAdd(argv: string[]): Promise<void>;
@@ -1,51 +0,0 @@
1
- import { parseFlags, stringFlag } from "../../lib/args.js";
2
- import { defaults, normalizeAccountName, readConfig, writeConfig } from "../../lib/config.js";
3
- import { promptSecret, promptValue } from "../../lib/prompt.js";
4
- import { getClientSecret, setClientSecret } from "../../lib/secret-store.js";
5
- export async function authAdd(argv) {
6
- const { args, flags } = parseFlags(argv);
7
- const account = normalizeAccountName(args[0]);
8
- const current = await readConfig();
9
- const previous = current.accounts[account];
10
- const now = new Date().toISOString();
11
- const apiUrl = stringFlag(flags, "api-url") || previous?.apiUrl || defaults().apiUrl;
12
- const oidcUrl = stringFlag(flags, "oidc-url") || previous?.oidcUrl || defaults().oidcUrl;
13
- const osUrl = stringFlag(flags, "os-url") || defaults().osUrl;
14
- const clientId = await value(flags, "client-id", "OIDC client id", previous?.clientId);
15
- const existingSecret = await getClientSecret(account);
16
- const clientSecret = await value(flags, "client-secret", "OIDC client secret", existingSecret, true);
17
- const scope = stringFlag(flags, "scope") || previous?.scope || defaults().scope;
18
- const redirectUri = stringFlag(flags, "redirect-uri") || previous?.redirectUri || defaults().redirectUri;
19
- const rootTenantId = optionalStringFlag(flags, "root-tenant") ?? previous?.rootTenantId ?? defaults().rootTenantId;
20
- await writeConfig({
21
- ...current,
22
- activeAccount: current.activeAccount ?? account,
23
- accounts: {
24
- ...current.accounts,
25
- [account]: {
26
- apiUrl,
27
- clientId,
28
- createdAt: previous?.createdAt ?? now,
29
- oidcUrl,
30
- osUrl,
31
- redirectUri,
32
- rootTenantId,
33
- scope,
34
- updatedAt: now
35
- }
36
- }
37
- });
38
- await setClientSecret(account, clientSecret);
39
- console.log(`Saved OIDC account '${account}'.`);
40
- }
41
- async function value(flags, name, label, defaultValue, secret = false) {
42
- const flag = stringFlag(flags, name);
43
- const resolved = flag || await (secret ? promptSecret(label, defaultValue) : promptValue(label, defaultValue));
44
- if (!resolved)
45
- throw new Error(`Missing --${name}`);
46
- return resolved;
47
- }
48
- function optionalStringFlag(flags, name) {
49
- const value = flags[name];
50
- return typeof value === "string" && value ? value : undefined;
51
- }
@@ -1 +0,0 @@
1
- export declare function authList(argv?: string[]): Promise<void>;