@seliseblocks/cli-os 0.1.5 → 0.2.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/AI_USAGE_GUIDE.md CHANGED
@@ -128,10 +128,10 @@ Then run with explicit flags so no prompt is reached:
128
128
  blocks new web <appName> --x-blocks-key <projectTenantId> --app-domain <appDomainOrUrl> --client-id <publicOidcClientId>
129
129
  ```
130
130
 
131
- `new web` also accepts `--blocks-api-url <url>` and `--oidc-url <url>`, same as `sdk client`
132
- below. `--blocks-api-url` defaults to `https://api.seliseblocks.com` if omitted - pass a
133
- different Data/IAM/Localization/OS gateway URL explicitly only if your project uses a
134
- non-default one. `--oidc-url` defaults to `https://iam.seliseblocks.com`.
131
+ `new web` also accepts `--blocks-api-url <url>` and `--oidc-url <url>`, same as `sdk client`
132
+ below. When `--blocks-api-url` is omitted, the scaffold derives it from the app domain as
133
+ `https://blocksapi.<registrable-domain>`; for example `https://dqrsf.slsblx.com` becomes
134
+ `https://blocksapi.slsblx.com`. Pass `--blocks-api-url` only when targeting a non-default Blocks gateway. `--oidc-url` defaults to `https://iam.seliseblocks.com`.
135
135
 
136
136
  Validate the scaffold:
137
137
 
@@ -160,10 +160,10 @@ The generated cert script uses the `selfsigned` Node dependency, so it works fro
160
160
  `sdk client` answers "I want to use the Blocks SDK - show me the client." It resolves this project's `@seliseblocks/client` config (same values `new web` scaffolds an app with) and prints a ready-to-paste `createBlocksClient(...)` snippet - **it never writes a file or mutates anything**. To scaffold a full app instead, use `new web` above.
161
161
 
162
162
  ```bash
163
- blocks sdk client --x-blocks-key <projectTenantId> --app-domain <appDomainOrUrl> --client-id <publicOidcClientId> --blocks-api-url https://api.seliseblocks.com
163
+ blocks sdk client --x-blocks-key <projectTenantId> --app-domain <appDomainOrUrl> --client-id <publicOidcClientId> --blocks-api-url https://api.seliseblocks.com
164
164
  ```
165
165
 
166
- As with `new web`, `--blocks-api-url` already defaults to `https://api.seliseblocks.com`; only pass it explicitly if your project uses a different gateway URL. Passing both `--app-domain` and `--client-id` skips the project lookup entirely, so it needs no CLI login at all - useful for a quick, non-interactive check. Omit either one and it resolves from the selected project instead (auto-picks when there's exactly one match, otherwise lists the options and asks you to pass the flag explicitly - it does not prompt or create anything, since this command is read-only). Use `--json` for the resolved values instead of the snippet.
166
+ Unlike `new web`, `sdk client` keeps `--blocks-api-url` defaulted to `https://api.seliseblocks.com`; only pass it explicitly if your project uses a different gateway URL. Passing both `--app-domain` and `--client-id` skips the project lookup entirely, so it needs no CLI login at all - useful for a quick, non-interactive check. Omit either one and it resolves from the selected project instead (auto-picks when there's exactly one match, otherwise lists the options and asks you to pass the flag explicitly - it does not prompt or create anything, since this command is read-only). Use `--json` for the resolved values instead of the snippet.
167
167
 
168
168
  ## Skills
169
169
 
@@ -181,9 +181,9 @@ Every other command below is project-scoped: it requires a project already selec
181
181
 
182
182
  Command families (run `blocks --help` for the full flag reference on each):
183
183
 
184
- - `iam users *`, `iam email available` - list/get/create/update/activate/deactivate, access grant/revoke, existence and email-availability checks.
185
- - `iam roles *` - list/get/create/update, assign-permissions, assignable.
186
- - `iam permissions *` - list/get/create/update, by-severity.
184
+ - `iam users *`, `iam email available` - list/get/create/update/activate/deactivate, access grant/revoke, existence and email-availability checks.
185
+ - `iam roles *` - list/get/create/update, assign-permissions, assignable. `assign-permissions` accepts permission resource strings and resolves them to itemIds before sending IAM's id-based mutation.
186
+ - `iam permissions *` - list/get/create/update, by-severity.
187
187
  - `iam resources *` - resource groups and feature flags (read-only).
188
188
  - `iam organizations *` - list/get/create/update, `my`, and organization config get/save.
189
189
  - `iam signup-settings *` - get/save tenant signup policy.
package/README.md CHANGED
@@ -10,7 +10,7 @@ CLI for SELISE Blocks Cloud.
10
10
  Install the npm package where you want to operate the CLI:
11
11
 
12
12
  ```bash
13
- npm install -g @seliseblocks/cli-os
13
+ npm install -g @seliseblocks/cli-os@latest
14
14
  blocks --version
15
15
  ```
16
16
 
@@ -95,9 +95,9 @@ Global options available on every command:
95
95
  | `blocks release status <buildId> [--json]` | Read Release build status by build id. Read-only. |
96
96
  | `blocks release builds list [repoId] [--repo-id <repoId>] [--json]` | List Release build details for a repository. When `repoId` is omitted, resolves it from the selected project's linked repo assets - auto-picked if there's exactly one, otherwise you're prompted to choose. Read-only. |
97
97
  | `blocks release builds get <buildId> [--json]` | Alias for `release status`. Read-only. |
98
- | `blocks new web <name> [--app-domain <domain>] [--client-id <oidcClientId>] [--x-blocks-key <tenantId>] [--blocks-api-url <url>] [--oidc-url <url>]` | Create a Vite React starter app that talks to Blocks exclusively through `@seliseblocks/client` (a single `createBlocksClient()` instance) using the SDK hosted IdP flow: `blocksClient.auth.idp.redirectToProvider()` on login click and `blocksClient.auth.idp.callback()` on `/login/callback`. Includes route guards, auto-refresh through `auth.oidc.refreshToken()`, live `auth`/`iam`/`data`/`localization` SDK examples, environment config, and safe `.gitignore` defaults. Uses the selected project (see `use`) unless `--x-blocks-key` overrides it. `--app-domain` and `--client-id` are resolved from the project record when omitted: the domain auto-picks if the project has exactly one, otherwise you're prompted to choose; the OIDC client is picked from the project's existing clients, or you can create a minimal one (display name + redirect URI) on the spot, or skip and register one later from the portal or `auth oidc-clients save`. `--blocks-api-url` defaults to `https://api.seliseblocks.com` if omitted - pass a different Data/IAM/Localization/OS gateway URL explicitly only if your project uses a non-default one. `--oidc-url` defaults to `https://iam.seliseblocks.com`. |
98
+ | `blocks new web <name> [--app-domain <domain>] [--client-id <oidcClientId>] [--x-blocks-key <tenantId>] [--blocks-api-url <url>] [--oidc-url <url>]` | Create a Vite React starter app that talks to Blocks exclusively through `@seliseblocks/client` (a single `createBlocksClient()` instance) using the SDK hosted IdP flow: `blocksClient.auth.idp.redirectToProvider()` on login click and `blocksClient.auth.idp.callback()` on `/login/callback`. Includes route guards, auto-refresh through `auth.oidc.refreshToken()`, live `auth`/`iam`/`data`/`localization` SDK examples, environment config, and safe `.gitignore` defaults. Uses the selected project (see `use`) unless `--x-blocks-key` overrides it. `--app-domain` and `--client-id` are resolved from the project record when omitted: the domain auto-picks if the project has exactly one, otherwise you're prompted to choose; the OIDC client is picked from the project's existing clients, or you can create a minimal one (display name + redirect URI) on the spot, or skip and register one later from the portal or `auth oidc-clients save`. When `--blocks-api-url` is omitted, `new web` derives it from the app domain as `https://blocksapi.<registrable-domain>`; for example `https://dqrsf.slsblx.com` becomes `https://blocksapi.slsblx.com`. Pass a different Data/IAM/Localization/OS gateway URL explicitly only if your project uses a non-default one. `--oidc-url` defaults to `https://iam.seliseblocks.com`. |
99
99
  | `blocks skill list [--json]` / `skill show <name> [--json]` / `skill add <name> [--dir <path>]` | Local-only, no cloud calls: list/print the bundled `blocks-skills/*/SKILL.md` agent context docs, or copy a skill's entire directory (`SKILL.md` plus any supporting files) into `<dir>/<name>/` (default `./blocks-skills`) for use in a project outside this monorepo. `list`'s output and `show`/`add`'s "unknown skill" error both point at the full public skill catalog in case the bundled set is out of date. |
100
- | `blocks sdk client [--app-domain <domain>] [--client-id <oidcClientId>] [--x-blocks-key <tenantId>] [--blocks-api-url <url>] [--oidc-url <url>] [--json]` | Read-only: resolves this project's `@seliseblocks/client` config (same values `new web` scaffolds with) and prints a ready-to-paste `createBlocksClient(...)` snippet. Passing both `--app-domain` and `--client-id` skips the project lookup entirely (no login required). Writes nothing - use `new web` to scaffold a full app. |
100
+ | `blocks sdk client [--app-domain <domain>] [--client-id <oidcClientId>] [--x-blocks-key <tenantId>] [--blocks-api-url <url>] [--oidc-url <url>] [--json]` | Read-only: resolves this project's `@seliseblocks/client` config and prints a ready-to-paste `createBlocksClient(...)` snippet. Unlike `new web`, the API URL defaults to `https://api.seliseblocks.com` unless `--blocks-api-url` is passed. Passing both `--app-domain` and `--client-id` skips the project lookup entirely (no login required). Writes nothing - use `new web` to scaffold a full app. |
101
101
 
102
102
  Use `--json` on commands when AI or automation needs machine-readable output. Use `--dry-run` before mutations and `--yes` only after approval.
103
103
 
@@ -6,6 +6,9 @@ import { parseCommand, selectedProject } from "../../../lib/workspace.js";
6
6
  export async function dataSchemaAggregation(argv) {
7
7
  const { flags } = parseCommand(argv);
8
8
  const projectKey = await selectedProject(flags);
9
+ const page = integerFlag(flags, "page", 1);
10
+ if (page < 1)
11
+ throw new Error("--page must be greater than or equal to 1");
9
12
  const result = await blocksRequest("/data/v4/schemas/aggregation", {
10
13
  impersonatedProjectAuth: true,
11
14
  ...requestContext(flags),
@@ -13,7 +16,7 @@ export async function dataSchemaAggregation(argv) {
13
16
  query: {
14
17
  CollectionName: stringFlag(flags, "collection-name") || undefined,
15
18
  Keyword: stringFlag(flags, "keyword") || undefined,
16
- PageNo: integerFlag(flags, "page", 1),
19
+ PageNo: page,
17
20
  PageSize: integerFlag(flags, "page-size", 100),
18
21
  ProjectKey: projectKey,
19
22
  SchemaName: stringFlag(flags, "schema-name") || undefined,
@@ -1,31 +1,21 @@
1
- import { normalizeAccountName, readConfig } from "../lib/config.js";
2
- import { readTokenStore, writeTokenStore } from "../lib/token-store.js";
3
- import { requestContext } from "../lib/request-context.js";
1
+ import { stringFlag } from "../lib/args.js";
2
+ import { stopProjectImpersonation } from "../lib/auth.js";
3
+ import { readConfig } from "../lib/config.js";
4
4
  import { clearSelectedProject, parseCommand } from "../lib/workspace.js";
5
5
  export async function deselectProject(argv) {
6
6
  const { flags } = parseCommand(argv);
7
+ const accountName = stringFlag(flags, "account") || undefined;
7
8
  const config = await readConfig();
8
- const { accountName } = requestContext(flags);
9
- const account = normalizeAccountName(accountName ?? config.activeAccount);
10
- const tenantId = await clearSelectedProject();
9
+ const tenantId = config.selectedProject?.tenantId;
11
10
  if (!tenantId) {
12
11
  console.log("No project is currently selected.");
13
12
  return;
14
13
  }
15
- const store = await readTokenStore();
16
- const projects = store.accounts[account]?.projects;
17
- if (projects && tenantId in projects) {
18
- const { [tenantId]: _removed, ...remainingProjects } = projects;
19
- await writeTokenStore({
20
- accounts: {
21
- ...store.accounts,
22
- [account]: {
23
- ...store.accounts[account],
24
- projects: remainingProjects
25
- }
26
- }
27
- });
28
- }
29
- console.log(`Deselected project tenant ${tenantId}.`);
14
+ // Stop-impersonation restores a fresh account refresh token and drops the
15
+ // project's cached token from the store, so there's nothing left to clean
16
+ // up here beyond the selection itself.
17
+ await stopProjectImpersonation(accountName, tenantId);
18
+ await clearSelectedProject();
19
+ console.log(`Deselected project tenant ${tenantId}. Account session restored.`);
30
20
  console.log("Run 'blocks use <tenantId>' to select a project again.");
31
21
  }
@@ -4,8 +4,11 @@ import { requestContext } from "../../lib/request-context.js";
4
4
  import { parseCommand } from "../../lib/workspace.js";
5
5
  export async function iamMe(argv = []) {
6
6
  const { flags } = parseCommand(argv);
7
+ // The server resets to the root tenant's identity for this endpoint
8
+ // regardless of which token calls it, so the impersonated project session
9
+ // works fine here too -- prefer it when a project is selected.
7
10
  const me = await blocksRequest("/iam/v4/iam/me", {
8
- accountAuth: true,
11
+ preferImpersonatedProjectAuth: true,
9
12
  ...requestContext(flags)
10
13
  });
11
14
  writeOutput(me, { ...flags, json: true });
@@ -1,4 +1,4 @@
1
- import { integerFlag, optionalBooleanFlag, optionalIntegerFlag, stringFlag } from "../../../lib/args.js";
1
+ import { booleanFlag, integerFlag, optionalBooleanFlag, optionalIntegerFlag, stringFlag } from "../../../lib/args.js";
2
2
  import { blocksRequest } from "../../../lib/api.js";
3
3
  import { compact, jsonBodyFlag, listFlag } from "../../../lib/json-flag.js";
4
4
  import { writeOutput } from "../../../lib/output.js";
@@ -7,6 +7,7 @@ import { parseCommand, selectedProject } from "../../../lib/workspace.js";
7
7
  export async function iamPermissionsList(argv) {
8
8
  const { flags } = parseCommand(argv);
9
9
  const projectKey = await selectedProject(flags);
10
+ const sortBy = stringFlag(flags, "sort-by");
10
11
  const filter = {
11
12
  ...(await jsonBodyFlag(flags)).filter,
12
13
  ...compact({
@@ -23,13 +24,13 @@ export async function iamPermissionsList(argv) {
23
24
  const body = {
24
25
  filter,
25
26
  organizationId: stringFlag(flags, "organization-id") || undefined,
26
- page: integerFlag(flags, "page", 1),
27
+ page: iamBackendPage(flags),
27
28
  pageSize: integerFlag(flags, "page-size", 20),
28
29
  roles: listFlag(flags, "roles"),
29
- sort: {
30
- isDescending: stringFlag(flags, "sort-desc") === "true",
31
- property: stringFlag(flags, "sort-by") || undefined
32
- }
30
+ sort: sortBy ? {
31
+ isDescending: booleanFlag(flags, "sort-desc"),
32
+ property: sortBy
33
+ } : undefined
33
34
  };
34
35
  const result = await blocksRequest("/iam/v4/iam/permissions", {
35
36
  body,
@@ -39,3 +40,9 @@ export async function iamPermissionsList(argv) {
39
40
  });
40
41
  writeOutput(result, flags);
41
42
  }
43
+ function iamBackendPage(flags) {
44
+ const page = integerFlag(flags, "page", 1);
45
+ if (page < 1)
46
+ throw new Error("--page must be greater than or equal to 1");
47
+ return page - 1;
48
+ }
@@ -8,10 +8,13 @@ import { parseCommand, selectedProject } from "../../../lib/workspace.js";
8
8
  export async function iamRolesAssignPermissions(argv) {
9
9
  const { args, flags } = parseCommand(argv);
10
10
  const slug = args[0] || stringFlag(flags, "slug", { required: true });
11
+ const addPermissions = listFlag(flags, "add-permissions");
12
+ const removePermissions = listFlag(flags, "remove-permissions");
13
+ const organizationId = stringFlag(flags, "organization-id") || undefined;
11
14
  const body = {
12
- addPermissions: listFlag(flags, "add-permissions"),
13
- oragnizationId: stringFlag(flags, "organization-id") || undefined,
14
- removePermissions: listFlag(flags, "remove-permissions"),
15
+ addPermissions,
16
+ organizationId,
17
+ removePermissions,
15
18
  slug
16
19
  };
17
20
  if (!body.addPermissions && !body.removePermissions) {
@@ -21,13 +24,71 @@ export async function iamRolesAssignPermissions(argv) {
21
24
  writeOutput({ dryRun: true, endpoint: "/iam/v4/iam/roles/assign-permissions", request: body }, flags);
22
25
  return;
23
26
  }
24
- await confirmMutation(flags, `Change permission assignments for IAM role '${slug}'.`);
25
27
  const projectKey = await selectedProject(flags);
28
+ const resolvedBody = {
29
+ ...body,
30
+ addPermissions: await resolvePermissionIdentifiers(addPermissions, organizationId, flags, projectKey),
31
+ removePermissions: await resolvePermissionIdentifiers(removePermissions, organizationId, flags, projectKey)
32
+ };
33
+ await confirmMutation(flags, `Change permission assignments for IAM role '${slug}'.`);
26
34
  const result = await blocksRequest("/iam/v4/iam/roles/assign-permissions", {
27
- body,
35
+ body: resolvedBody,
28
36
  impersonatedProjectAuth: true,
29
37
  ...requestContext(flags),
30
38
  projectTenantId: projectKey
31
39
  });
32
40
  writeOutput(result, flags);
33
41
  }
42
+ async function resolvePermissionIdentifiers(identifiers, organizationId, flags, projectKey) {
43
+ if (!identifiers?.length)
44
+ return identifiers;
45
+ const resources = identifiers.filter((item) => item.includes("::"));
46
+ if (resources.length === 0)
47
+ return identifiers;
48
+ const response = await blocksRequest("/iam/v4/iam/permissions", {
49
+ body: {
50
+ filter: {
51
+ isArchived: false,
52
+ resources
53
+ },
54
+ organizationId,
55
+ page: 0,
56
+ pageSize: Math.max(resources.length, 20)
57
+ },
58
+ impersonatedProjectAuth: true,
59
+ ...requestContext(flags),
60
+ projectTenantId: projectKey
61
+ });
62
+ const permissions = extractPermissionRows(response);
63
+ const byResource = new Map();
64
+ for (const permission of permissions) {
65
+ const resource = stringProperty(permission, "resource");
66
+ const itemId = stringProperty(permission, "itemId") || stringProperty(permission, "_id") || stringProperty(permission, "id");
67
+ if (resource && itemId)
68
+ byResource.set(resource, itemId);
69
+ }
70
+ const missing = resources.filter((resource) => !byResource.has(resource));
71
+ if (missing.length > 0) {
72
+ throw new Error(`Could not resolve IAM permission resource(s) to itemId: ${missing.join(", ")}`);
73
+ }
74
+ return identifiers.map((identifier) => byResource.get(identifier) ?? identifier);
75
+ }
76
+ function extractPermissionRows(response) {
77
+ if (Array.isArray(response))
78
+ return response.filter(isRecord);
79
+ if (!isRecord(response))
80
+ return [];
81
+ for (const key of ["data", "Data", "items", "Items", "permissions", "Permissions"]) {
82
+ const value = response[key];
83
+ if (Array.isArray(value))
84
+ return value.filter(isRecord);
85
+ }
86
+ return [];
87
+ }
88
+ function isRecord(value) {
89
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
90
+ }
91
+ function stringProperty(record, name) {
92
+ const value = record[name] ?? record[name[0].toUpperCase() + name.slice(1)];
93
+ return typeof value === "string" ? value : "";
94
+ }
@@ -1,4 +1,4 @@
1
- import { integerFlag, stringFlag } from "../../../lib/args.js";
1
+ import { booleanFlag, integerFlag, stringFlag } from "../../../lib/args.js";
2
2
  import { blocksRequest } from "../../../lib/api.js";
3
3
  import { compact, jsonBodyFlag, listFlag } from "../../../lib/json-flag.js";
4
4
  import { writeOutput } from "../../../lib/output.js";
@@ -7,6 +7,7 @@ import { parseCommand, selectedProject } from "../../../lib/workspace.js";
7
7
  export async function iamRolesList(argv) {
8
8
  const { flags } = parseCommand(argv);
9
9
  const projectKey = await selectedProject(flags);
10
+ const sortBy = stringFlag(flags, "sort-by");
10
11
  const filter = {
11
12
  ...(await jsonBodyFlag(flags)).filter,
12
13
  ...compact({
@@ -17,12 +18,12 @@ export async function iamRolesList(argv) {
17
18
  const body = {
18
19
  filter,
19
20
  organizationId: stringFlag(flags, "organization-id") || undefined,
20
- page: integerFlag(flags, "page", 1),
21
+ page: iamBackendPage(flags),
21
22
  pageSize: integerFlag(flags, "page-size", 20),
22
- sort: {
23
- isDescending: stringFlag(flags, "sort-desc") === "true",
24
- property: stringFlag(flags, "sort-by") || undefined
25
- }
23
+ sort: sortBy ? {
24
+ isDescending: booleanFlag(flags, "sort-desc"),
25
+ property: sortBy
26
+ } : undefined
26
27
  };
27
28
  const result = await blocksRequest("/iam/v4/iam/roles", {
28
29
  body,
@@ -32,3 +33,9 @@ export async function iamRolesList(argv) {
32
33
  });
33
34
  writeOutput(result, flags);
34
35
  }
36
+ function iamBackendPage(flags) {
37
+ const page = integerFlag(flags, "page", 1);
38
+ if (page < 1)
39
+ throw new Error("--page must be greater than or equal to 1");
40
+ return page - 1;
41
+ }
@@ -1,9 +1,11 @@
1
1
  import { parseFlags, stringFlag } from "../lib/args.js";
2
- import { pollDeviceToken, requestDeviceAuthorization } from "../lib/auth.js";
2
+ import { getImpersonatedProjectSession, pollDeviceToken, requestDeviceAuthorization } from "../lib/auth.js";
3
3
  import { getAccountProfile, readConfig, writeConfig } from "../lib/config.js";
4
4
  import { openBrowser } from "../lib/open-browser.js";
5
+ import { listProjectGroups } from "../lib/project-info.js";
5
6
  import { applyAccountToken } from "../lib/token.js";
6
7
  import { readTokenStore, writeTokenStore } from "../lib/token-store.js";
8
+ import { readWorkspaceConfig } from "../lib/workspace.js";
7
9
  export async function login(argv) {
8
10
  const { flags } = parseFlags(argv);
9
11
  const accountOverride = stringFlag(flags, "account");
@@ -32,4 +34,33 @@ export async function login(argv) {
32
34
  await writeConfig(next.config);
33
35
  await writeTokenStore(next.store);
34
36
  console.log("Login done.");
37
+ const workspace = await readWorkspaceConfig();
38
+ const rememberedTenantId = workspace.project?.tenantId ?? next.config.selectedProject?.tenantId;
39
+ if (rememberedTenantId) {
40
+ try {
41
+ await getImpersonatedProjectSession(name, rememberedTenantId);
42
+ console.log(`Re-selected project tenant ${rememberedTenantId}.`);
43
+ }
44
+ catch (error) {
45
+ console.log(`Could not re-select project tenant ${rememberedTenantId}: ${error instanceof Error ? error.message : String(error)}`);
46
+ console.log("Run 'blocks use <tenantId>' to select a project.");
47
+ }
48
+ return;
49
+ }
50
+ try {
51
+ const groups = await listProjectGroups(flags);
52
+ const projects = groups.flatMap((group) => group.projects ?? []);
53
+ if (projects.length === 0) {
54
+ console.log("No projects found for this account.");
55
+ return;
56
+ }
57
+ console.log("Available projects:");
58
+ for (const project of projects) {
59
+ console.log(` ${project.tenantId ?? "-"} ${project.name ?? "-"} ${project.environment ?? "-"}`);
60
+ }
61
+ console.log("Run 'blocks use <tenantId>' to select one.");
62
+ }
63
+ catch (error) {
64
+ console.log(`Could not list projects: ${error instanceof Error ? error.message : String(error)}`);
65
+ }
35
66
  }
@@ -2,6 +2,7 @@ import { stringFlag } from "../../lib/args.js";
2
2
  import { blocksRequest } from "../../lib/api.js";
3
3
  import { confirmMutation } from "../../lib/confirm.js";
4
4
  import { defaults, readConfig, writeConfig } from "../../lib/config.js";
5
+ import { apiUrlFromAppDomain } from "../../lib/domains.js";
5
6
  import { CliActionableError } from "../../lib/errors.js";
6
7
  import { findProjectByTenantId } from "../../lib/project-info.js";
7
8
  import { promptText, selectFromList } from "../../lib/prompt.js";
@@ -16,9 +17,9 @@ export async function newWeb(argv) {
16
17
  const tenantId = stringFlag(flags, "x-blocks-key") || (await selectedProject(flags));
17
18
  const explicitAppDomain = stringFlag(flags, "app-domain");
18
19
  const project = explicitAppDomain ? {} : (await findProjectByTenantId(tenantId, flags)).project;
19
- const apiUrl = stringFlag(flags, "blocks-api-url", { defaultValue: defaults().apiUrl });
20
20
  const oidcUrl = stringFlag(flags, "oidc-url", { defaultValue: defaults().oidcUrl });
21
21
  const appDomain = await resolveAppDomain(project, flags);
22
+ const apiUrl = stringFlag(flags, "blocks-api-url") || apiUrlFromAppDomain(appDomain);
22
23
  const oidcClientId = await resolveOidcClientId(tenantId, appDomain, name, flags);
23
24
  await scaffoldWebProject({
24
25
  apiUrl,
@@ -45,7 +45,7 @@ function redactSecret(body) {
45
45
  return body;
46
46
  const redacted = {};
47
47
  for (const [key, value] of Object.entries(pairs)) {
48
- redacted[key] = /secret|password|key$/i.test(key) ? "***" : value;
48
+ redacted[key] = /(secret|password|key$)/i.test(key) ? "***" : value;
49
49
  }
50
50
  return { ...body, keyValuePairs: redacted };
51
51
  }
@@ -1,9 +1,24 @@
1
- import { saveSelectedProject } from "../lib/workspace.js";
1
+ import { stringFlag } from "../lib/args.js";
2
+ import { getImpersonatedProjectSession, stopProjectImpersonation } from "../lib/auth.js";
3
+ import { readConfig } from "../lib/config.js";
4
+ import { parseCommand, saveSelectedProject } from "../lib/workspace.js";
2
5
  export async function useProject(argv) {
3
- const tenantId = argv[0];
6
+ const { args, flags } = parseCommand(argv);
7
+ const tenantId = args[0];
4
8
  if (!tenantId)
5
9
  throw new Error("Missing project tenant id.");
10
+ const accountName = stringFlag(flags, "account") || undefined;
11
+ const config = await readConfig();
12
+ const previousTenantId = config.selectedProject?.tenantId;
13
+ if (previousTenantId && previousTenantId !== tenantId) {
14
+ // Switching projects needs a fresh account refresh token to start the new
15
+ // impersonation -- the one used to start the old impersonation was
16
+ // already consumed by the server, so the old session must be stopped
17
+ // first to get a new one back.
18
+ await stopProjectImpersonation(accountName, previousTenantId);
19
+ }
6
20
  await saveSelectedProject(tenantId);
21
+ const project = await getImpersonatedProjectSession(accountName, tenantId);
7
22
  console.log(`Selected project tenant ${tenantId}`);
8
- console.log("Root/account session is kept for OS project APIs. Project impersonation is created lazily when a service command needs it.");
23
+ console.log(`Project session ready for tenant ${project.tenantId}.`);
9
24
  }
package/dist/index.js CHANGED
@@ -500,7 +500,9 @@ Auth:
500
500
  Device-code login. Prints a verification URL and user code, opens the
501
501
  browser to the verification page when possible so you only need to click
502
502
  approve, then polls until the device is authorized; stores account access
503
- and refresh tokens and auto-refreshes later.
503
+ and refresh tokens and auto-refreshes later. If a project was previously
504
+ selected, re-impersonates it automatically; otherwise lists projects and
505
+ prompts you to run 'blocks use <tenantId>'.
504
506
 
505
507
  blocks auth status [--json]
506
508
  Show only whether account/project access and refresh tokens are missing,
@@ -518,8 +520,9 @@ Auth:
518
520
 
519
521
  Projects:
520
522
  blocks projects list [--json]
521
- List accessible Blocks projects via /os/v4/Project/Gets using the account
522
- token. Read-only.
523
+ List accessible Blocks projects via /os/v4/Project/Gets. Uses the
524
+ impersonated project session when a project is selected, otherwise the
525
+ account token. Read-only.
523
526
 
524
527
  blocks projects get [tenantId] [--deployment] [--json]
525
528
  Read one project from Project/Gets. Uses selected project when tenantId is
@@ -528,20 +531,24 @@ Projects:
528
531
  to resolve its target. Read-only.
529
532
 
530
533
  blocks use <project-tenant-id>
531
- Save the selected project tenant globally and in blocks.json when present.
532
- Does not call cloud APIs.
534
+ Save the selected project tenant globally and in blocks.json when present,
535
+ then immediately impersonate it. If a different project was selected,
536
+ stops that impersonation first to reclaim a fresh account refresh token
537
+ before starting the new one.
533
538
 
534
539
  blocks deselect
535
- Clear the selected project tenant (globally and in blocks.json) and drop
536
- its cached impersonation token. Use this to recover when an impersonated
537
- project token has expired or failed, then run 'blocks use <tenantId>'
538
- again to reselect and re-impersonate.
540
+ Stop the active impersonation (restoring a fresh account refresh token),
541
+ then clear the selected project tenant (globally and in blocks.json) and
542
+ drop its cached impersonation token. Run 'blocks use <tenantId>' again to
543
+ reselect and re-impersonate.
539
544
 
540
545
  IAM:
541
546
  blocks iam me [--json]
542
- Read the current user from IAM using the account token (bootstrapping/CLI
543
- operator identity, not a project resource). Every other iam * command below
544
- is project-scoped: it requires a selected project and calls IAM using an
547
+ Read the current user from IAM (bootstrapping/CLI operator identity, not
548
+ a project resource). Uses the impersonated project session when a project
549
+ is selected, otherwise the account token -- the server always resolves
550
+ this to the root identity either way. Every other iam * command below is
551
+ project-scoped: it requires a selected project and calls IAM using an
545
552
  impersonated project token only, never the account token.
546
553
 
547
554
  Users (/iam/v4/iam/users*):
@@ -1082,9 +1089,11 @@ Scaffold:
1082
1089
  name + redirect URI, active, registered as a Blocks OIDC identity
1083
1090
  provider) on the spot, or skip and register one later from the portal or
1084
1091
  'auth oidc-clients save'.
1085
- --blocks-api-url defaults to https://api.seliseblocks.com if omitted -
1086
- pass a different Data/IAM/Localization/OS gateway URL explicitly only if
1087
- your project uses a non-default one.
1092
+ If --blocks-api-url is omitted, it is derived from the app domain:
1093
+ https://blocksapi.<registrable-domain> (for example, app domain
1094
+ https://dqrsf.slsblx.com uses https://blocksapi.slsblx.com). Pass a
1095
+ different Data/IAM/Localization/OS gateway URL explicitly only if your
1096
+ project uses a non-default one.
1088
1097
  --oidc-url defaults to https://iam.seliseblocks.com.
1089
1098
 
1090
1099
  Skills:
@@ -1102,11 +1111,12 @@ Skills:
1102
1111
  SDK:
1103
1112
  blocks sdk client [--app-domain <domain>] [--client-id <oidcClientId>]
1104
1113
  [--x-blocks-key <tenantId>] [--blocks-api-url <url>] [--oidc-url <url>] [--json]
1105
- Read-only: "I want to use the Blocks SDK -- show me the client." Resolves this
1106
- project's @seliseblocks/client config (same values 'new web' scaffolds with,
1107
- using the selected project unless --x-blocks-key overrides it, and the
1108
- project's registered domain/OIDC client when --app-domain/--client-id are
1109
- omitted) and prints a ready-to-paste createBlocksClient(...) snippet.
1114
+ Read-only: "I want to use the Blocks SDK -- show me the client." Resolves this
1115
+ project's @seliseblocks/client config using the selected project unless
1116
+ --x-blocks-key overrides it, and the project's registered domain/OIDC client
1117
+ when --app-domain/--client-id are omitted. Its API URL defaults to
1118
+ https://api.seliseblocks.com unless --blocks-api-url is passed.
1119
+ Prints a ready-to-paste createBlocksClient(...) snippet.
1110
1120
  Passing both --app-domain and --client-id skips the project lookup entirely
1111
1121
  (no login required). Never writes a file; to scaffold a new app use 'new web'.
1112
1122
  `);
package/dist/lib/api.d.ts CHANGED
@@ -5,6 +5,7 @@ type RequestOptions = {
5
5
  body?: unknown;
6
6
  impersonatedProjectAuth?: boolean;
7
7
  method?: string;
8
+ preferImpersonatedProjectAuth?: boolean;
8
9
  projectTenantId?: string;
9
10
  query?: Record<string, string | number | boolean | string[] | undefined>;
10
11
  };
package/dist/lib/api.js CHANGED
@@ -31,8 +31,26 @@ export async function blocksRequest(path, options = {}) {
31
31
  if (options.impersonatedProjectAuth) {
32
32
  const project = await getImpersonatedProjectSession(options.accountName, options.projectTenantId, { forceRefresh });
33
33
  headers.Authorization = `Bearer ${project.accessToken}`;
34
+ // The impersonated token is minted and signed by the root tenant's IdP --
35
+ // its JWKS only exists under the root tenant, so signature validation
36
+ // needs x-blocks-key pointed at root, not the target project. The actual
37
+ // tenant-data scoping comes from a claim already inside the validated
38
+ // token, not from this header.
34
39
  headers["x-blocks-key"] = project.accountTenant;
35
40
  }
41
+ if (options.preferImpersonatedProjectAuth) {
42
+ const tenantId = options.projectTenantId ?? config.selectedProject?.tenantId;
43
+ if (tenantId) {
44
+ const project = await getImpersonatedProjectSession(options.accountName, tenantId, { forceRefresh });
45
+ headers.Authorization = `Bearer ${project.accessToken}`;
46
+ headers["x-blocks-key"] = project.accountTenant;
47
+ }
48
+ else {
49
+ const account = await getAccountSession(options.accountName, { forceRefresh });
50
+ headers.Authorization = `Bearer ${account.accessToken}`;
51
+ headers["x-blocks-key"] = account.accountTenant;
52
+ }
53
+ }
36
54
  return fetch(url, {
37
55
  body: options.body === undefined ? undefined : isFormData ? options.body : JSON.stringify(options.body),
38
56
  headers,
@@ -41,14 +59,22 @@ export async function blocksRequest(path, options = {}) {
41
59
  throw new Error(`Blocks API request failed for ${url.origin}${url.pathname}: ${error.message}`);
42
60
  });
43
61
  };
62
+ const method = options.method ?? (options.body === undefined ? "GET" : "POST");
44
63
  let response = await send(false);
45
- if (response.status === 401 && (options.accountAuth || options.impersonatedProjectAuth)) {
64
+ if (response.status === 401 && (options.accountAuth || options.impersonatedProjectAuth || options.preferImpersonatedProjectAuth)) {
46
65
  // The locally cached expiry said the token was still good, but the server
47
66
  // rejected it anyway (early revocation, clock skew, forced logout server-side).
48
67
  // Force one refresh-and-retry before giving up -- this is what actually
49
68
  // prevents a spurious 're-run blocks login' when the refresh token is still valid.
50
69
  response = await send(true);
51
70
  }
71
+ else if (response.status === 500 && method === "GET") {
72
+ // Some tenant-scoped read endpoints (mfa config, signup-settings) intermittently
73
+ // 500 with a JWKS/kid lookup failure right after impersonation, then succeed on
74
+ // an immediate identical retry once the signing-key cache catches up. Safe to
75
+ // retry blindly here because GET is idempotent.
76
+ response = await send(false);
77
+ }
52
78
  const text = await response.text();
53
79
  const data = parseJson(text);
54
80
  if (!response.ok) {
@@ -31,4 +31,5 @@ export declare function pollDeviceToken(profile: AccountProfile, device: DeviceA
31
31
  export declare function getAccountSession(accountOverride?: string, options?: SessionOptions): Promise<AccountSession>;
32
32
  export declare function selectProject(tenantId: string): Promise<void>;
33
33
  export declare function getImpersonatedProjectSession(accountOverride?: string, tenantOverride?: string, options?: SessionOptions): Promise<ProjectSession>;
34
+ export declare function stopProjectImpersonation(accountOverride?: string, tenantOverride?: string): Promise<void>;
34
35
  export declare function revokeCurrentSession(accountOverride?: string): Promise<void>;
package/dist/lib/auth.js CHANGED
@@ -194,6 +194,57 @@ export async function getImpersonatedProjectSession(accountOverride, tenantOverr
194
194
  await writeTokenStore(next.store);
195
195
  return projectSessionFromToken(name, tenantId, next.store.accounts[name].projects[tenantId], account.accountTenant);
196
196
  }
197
+ // Ends the active project impersonation and restores a fresh, refreshable
198
+ // account-level session. The IAM server revokes the account refresh token the
199
+ // moment it's used to start an impersonation (see impersonateProject) and
200
+ // only ever hands back a project-scoped one in exchange -- calling
201
+ // '/impersonation/stop' is the only way to get a new account-level refresh
202
+ // token back. No-ops if no project is selected or nothing was ever
203
+ // impersonated for it.
204
+ export async function stopProjectImpersonation(accountOverride, tenantOverride) {
205
+ const config = await readConfig();
206
+ const { name, profile } = getAccountProfile(config, accountOverride);
207
+ const tenantId = tenantOverride ?? config.selectedProject?.tenantId;
208
+ if (!tenantId)
209
+ return;
210
+ const store = await readTokenStore();
211
+ if (!store.accounts[name]?.projects?.[tenantId]?.refreshToken)
212
+ return;
213
+ // The stop endpoint requires a currently-valid bearer token to authenticate
214
+ // the call, so refresh the project session first if it's expiring.
215
+ const project = await getImpersonatedProjectSession(name, tenantId);
216
+ const beforeStop = await readTokenStore();
217
+ const projectToken = beforeStop.accounts[name]?.projects?.[tenantId];
218
+ if (!projectToken?.refreshToken)
219
+ return;
220
+ const refreshed = await postStopImpersonation(profile.apiUrl, project.accessToken, project.accountTenant, projectToken.refreshToken);
221
+ const latestConfig = await readConfig();
222
+ const latestStore = await readTokenStore();
223
+ const next = applyAccountToken(latestConfig, latestStore, name, profile.clientId, refreshed);
224
+ const { [tenantId]: _removed, ...remainingProjects } = next.store.accounts[name]?.projects ?? {};
225
+ next.store.accounts[name] = {
226
+ ...next.store.accounts[name],
227
+ projects: remainingProjects
228
+ };
229
+ await writeConfig(next.config);
230
+ await writeTokenStore(next.store);
231
+ }
232
+ async function postStopImpersonation(apiUrl, accessToken, accountTenant, refreshToken) {
233
+ const response = await fetch(new URL("/iam/v4/auth/impersonation/stop", apiUrl), {
234
+ body: JSON.stringify({ refresh_token: refreshToken }),
235
+ headers: {
236
+ Accept: "application/json",
237
+ Authorization: `Bearer ${accessToken}`,
238
+ "Content-Type": "application/json",
239
+ "x-blocks-key": accountTenant
240
+ },
241
+ method: "POST"
242
+ });
243
+ const data = parseJson(await response.text());
244
+ if (response.ok && !data.error)
245
+ return data;
246
+ throw new Error(data.error_description ?? data.error ?? `Stop impersonation failed with HTTP ${response.status}`);
247
+ }
197
248
  export async function revokeCurrentSession(accountOverride) {
198
249
  const config = await readConfig();
199
250
  const store = await readTokenStore();
@@ -0,0 +1,2 @@
1
+ export declare function hostFromAppDomain(appDomain: string): string;
2
+ export declare function apiUrlFromAppDomain(appDomain: string): string;
@@ -0,0 +1,14 @@
1
+ export function hostFromAppDomain(appDomain) {
2
+ try {
3
+ return new URL(appDomain).host;
4
+ }
5
+ catch {
6
+ return appDomain.replace(/^https?:\/\//, "").replace(/\/.*$/, "");
7
+ }
8
+ }
9
+ export function apiUrlFromAppDomain(appDomain) {
10
+ const hostname = hostFromAppDomain(appDomain).split(":")[0].toLowerCase();
11
+ const labels = hostname.split(".").filter(Boolean);
12
+ const registrableDomain = labels.length >= 2 ? labels.slice(-2).join(".") : hostname;
13
+ return `https://blocksapi.${registrableDomain}`;
14
+ }
@@ -1,12 +1,16 @@
1
1
  import { blocksRequest } from "./api.js";
2
2
  import { requestContext } from "./request-context.js";
3
3
  import { selectedProject } from "./workspace.js";
4
- // Project metadata (Project/Gets, Project/GetAsset) always uses the account
5
- // token, never the impersonated project token -- these endpoints operate at
6
- // the account/tenant-group level, above any single project's own API surface.
4
+ // Project metadata (Project/Gets, Project/GetAsset) is authorized at the
5
+ // account/tenant-group level, above any single project's own API surface --
6
+ // but the platform's permission check rebuilds to the root tenant while
7
+ // impersonating, and the underlying query filters by user id rather than
8
+ // tenant, so the impersonated project session works here too. Prefer it when
9
+ // a project is selected (avoids an extra account-session refresh mid-project
10
+ // work); fall back to the account token when nothing is selected yet.
7
11
  export async function listProjectGroups(flags) {
8
12
  return blocksRequest("/os/v4/Project/Gets", {
9
- accountAuth: true,
13
+ preferImpersonatedProjectAuth: true,
10
14
  query: { page: 0, pageSize: 100, tenantGroupId: "" },
11
15
  ...requestContext(flags)
12
16
  });
@@ -32,7 +36,7 @@ export async function resolveSelectedProject(flags) {
32
36
  }
33
37
  export async function getProjectAssets(tenantGroupId, flags) {
34
38
  return blocksRequest("/os/v4/Project/GetAsset", {
35
- accountAuth: true,
39
+ preferImpersonatedProjectAuth: true,
36
40
  query: { page: 0, pageSize: 100, tenantGroupId },
37
41
  ...requestContext(flags)
38
42
  });
@@ -1,2 +1,3 @@
1
+ import { hostFromAppDomain } from "../domains.js";
2
+ export { hostFromAppDomain };
1
3
  export declare function write(root: string, path: string, content: string): Promise<void>;
2
- export declare function hostFromAppDomain(appDomain: string): string;
@@ -1,15 +1,9 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
+ import { hostFromAppDomain } from "../domains.js";
4
+ export { hostFromAppDomain };
3
5
  export async function write(root, path, content) {
4
6
  const fullPath = join(root, path);
5
7
  await mkdir(dirname(fullPath), { recursive: true });
6
8
  await writeFile(fullPath, `${content.endsWith("\n") ? content : `${content}\n`}`);
7
9
  }
8
- export function hostFromAppDomain(appDomain) {
9
- try {
10
- return new URL(appDomain).host;
11
- }
12
- catch {
13
- return appDomain.replace(/^https?:\/\//, "").replace(/\/.*$/, "");
14
- }
15
- }
@@ -18,7 +18,7 @@ export async function writeRootFiles(root, options) {
18
18
  },
19
19
  dependencies: {
20
20
  "@radix-ui/react-dropdown-menu": "^2.1.24",
21
- "@seliseblocks/client": "^0.1.1",
21
+ "@seliseblocks/client": "^0.1.3",
22
22
  "@tanstack/react-query": "^5.101.4",
23
23
  clsx: "^2.1.1",
24
24
  "lucide-react": "^1.28.0",
@@ -103,6 +103,7 @@ blocks iam roles assign-permissions editor --add-permissions content::publish --
103
103
 
104
104
  - **CLI mutations are project-scoped, not account-scoped** — `blocks iam roles create/update/assign-permissions` and `blocks iam permissions create/update` all require a selected project (`blocks use <tenantId>` or `--project <tenantId>`) and run against the impersonated-project token; `blocks iam me` is the one IAM command that uses the account token instead, so don't expect `iam me`'s auth context to carry over to these.
105
105
  - **Role hierarchy and permission assignment key off `slug`**, not `itemId` — grab it from `roles.list()`/`roles.get()` (or `blocks iam roles list/get`) before calling `assignPermissions`.
106
+ - **Permission assignment ultimately uses permission `itemId`s** — the CLI resolves `resource` strings like `content::publish` before mutation; SDK/backend callers should pass permission ids directly in `addPermissions` / `removePermissions`.
106
107
  - **`roles.assignPermissions` is additive/subtractive** (`addPermissions[]` / `removePermissions[]` in one call), not a full-set replace — compute the delta from what's checked/unchecked, don't resend the entire permission list as "adds."
107
108
  - **`roles.assignable()` scopes to the caller** — always populate role pickers from it rather than `roles.list()`, so an admin can't be shown (or attempt to grant) a role above their own authority.
108
109
  - **Never fire a create/update/assign-permissions call — CLI or SDK — without a human confirming that specific change first** (a reviewed `--dry-run` plus explicit go-ahead on the CLI, an explicit in-UI confirm for the SDK) — no auto-provisioning "default roles," no agent-initiated cleanup of permissions, no batch edits without a per-change confirm.
@@ -66,7 +66,7 @@ Run `blocks init` once per project directory to create `blocks.json`, `blocks/da
66
66
  Then route to what the user actually wants:
67
67
  - Building a frontend from scratch → resolve the app's public OIDC client first, then scaffold:
68
68
  - `blocks auth oidc-clients list --json` — check whether a client already registered for this project fits. If none fits, create one directly (no portal visit needed): `blocks auth oidc-clients save --client-display-name <appName> --redirect-uris https://<domain>:5173/login/callback --scope "openid profile" --require-pkce --register-as-identity-provider --dry-run --json`, then re-run with `--yes` after showing the dry-run output and getting approval. See the blocks-iam-sso-oidc-configuration skill for the full decision tree and field-level gotchas.
69
- - `blocks new web <name> --x-blocks-key <tenantId> --app-domain <domain> --blocks-api-url https://api.seliseblocks.com --client-id <the-resolved-client-id>`. **Always pass `--client-id` and `--app-domain` explicitly** — omitting either drops `new web` into an interactive pick-list prompt with no non-interactive escape (not even to "skip"), which hangs a scripted/agent run with no stdin to answer it.
69
+ - `blocks new web <name> --x-blocks-key <tenantId> --app-domain <domain> --client-id <the-resolved-client-id>`. **Always pass `--client-id` and `--app-domain` explicitly** — omitting either drops `new web` into an interactive pick-list prompt with no non-interactive escape (not even to "skip"), which hangs a scripted/agent run with no stdin to answer it. Omit `--blocks-api-url` unless the project uses a non-default gateway; the scaffold derives it from the app domain, e.g. `https://dqrsf.slsblx.com` -> `https://blocksapi.slsblx.com`.
70
70
  - Defining data / CRUD / localization / release on an existing project → hand off to the matching skill; the project is already selected via `blocks use`, so its commands can proceed directly.
71
71
 
72
72
  ## Gotchas
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seliseblocks/cli-os",
3
- "version": "0.1.5",
3
+ "version": "0.2.1",
4
4
  "description": "CLI for SELISE Blocks project setup and configuration.",
5
5
  "license": "MIT",
6
6
  "type": "module",