rkb-cli 0.3.0-beta.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 ADDED
@@ -0,0 +1,18 @@
1
+ # rkb-cli
2
+
3
+ Requires Node.js 20 or later. Run `rkb --help` for usage.
4
+
5
+ Run `rkb login` to authorize the official Alibaba Cloud CLI OAuth application and obtain temporary STS credentials. Requires `aliyun >= 3.3.0`; no manual AK/SK is needed. Set `RKB_EXTERNAL_ALIYUN_CLI` only if the binary is not on PATH. Credentials are stored in `~/.rkb-external/aliyun/config.json` with owner-only file permissions. Use `--force` to authorize again. Run `rkb logout` to revoke the refresh token and remove this profile, or `logout --local` for local cleanup. POP APIs must enable AccessKey authentication and authorize the caller through RAM.
6
+
7
+ Configuration: `~/.rkb-external/config.yaml`; environment prefix: `RKB_EXTERNAL_ENV_`.
8
+
9
+ Available commands:
10
+
11
+ - `rkb doc`
12
+ - `rkb doc publish`
13
+ - `rkb login`
14
+ - `rkb logout`
15
+ - `rkb space`
16
+ - `rkb space list`
17
+ - `rkb space use`
18
+ - `rkb whoami`
package/bin/run.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execute } from "@oclif/core";
4
+
5
+ await execute({ dir: import.meta.url });
package/edition.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "edition": "external",
3
+ "features": [
4
+ "auth.aliyun",
5
+ "identity",
6
+ "space.list",
7
+ "space.use",
8
+ "doc.publish"
9
+ ],
10
+ "commands": [
11
+ "doc",
12
+ "doc:publish",
13
+ "login",
14
+ "logout",
15
+ "space",
16
+ "space:list",
17
+ "space:use",
18
+ "whoami"
19
+ ]
20
+ }
@@ -0,0 +1,338 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdir, readFile, writeFile, rename, rm } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import OpenApiClient, { Config, Params, OpenApiRequest, } from "@alicloud/openapi-client";
6
+ import { RuntimeOptions } from "@alicloud/tea-util";
7
+ import { getConfigDir } from "../../config/index.js";
8
+ import { CapabilityError } from "../../core/result.js";
9
+ const profileName = "rkb-external";
10
+ const oauthEndpoint = "https://oauth.aliyun.com/v1";
11
+ // Used only to refresh tokens issued by the official CLI's own login flow.
12
+ const officialClientId = "4038181954557748008";
13
+ const Client = (typeof OpenApiClient === "function"
14
+ ? OpenApiClient
15
+ : OpenApiClient
16
+ .default);
17
+ function object(value) {
18
+ if (!value || typeof value !== "object" || Array.isArray(value))
19
+ throw new CapabilityError("ALIYUN_INVALID_RESPONSE", "Alibaba Cloud returned an invalid response.");
20
+ return value;
21
+ }
22
+ function required(value) {
23
+ if (typeof value !== "string" || !value.trim())
24
+ throw new CapabilityError("ALIYUN_INVALID_RESPONSE", "Alibaba Cloud returned incomplete credentials or identity.");
25
+ return value;
26
+ }
27
+ function loginRequired() {
28
+ return new CapabilityError("ALIYUN_LOGIN_REQUIRED", "Run rkb login to create or renew the external Alibaba Cloud login.");
29
+ }
30
+ // Do not pass ambient AK/SK or a different account's profile to the login process.
31
+ export function officialCliEnvironment(source) {
32
+ return Object.fromEntries(Object.entries(source).filter(([key]) => !/^(ALIBABA_CLOUD|ALIBABACLOUD|ALICLOUD)_(ACCESS_KEY|SECURITY_TOKEN|PROFILE|REGION|ENDPOINT|STS_|BEARER_|OIDC_|ROLE_|CREDENTIALS_URI|EXTERNAL_)/.test(key) &&
33
+ !/^(ACCESS_KEY_ID|ACCESS_KEY_SECRET|SECURITY_TOKEN|REGION_ID|REGION)$/.test(key)));
34
+ }
35
+ export const runOfficialCli = (args, options) => new Promise((resolve, reject) => {
36
+ options.signal.throwIfAborted();
37
+ const child = spawn(process.env.RKB_EXTERNAL_ALIYUN_CLI || "aliyun", args, {
38
+ shell: false,
39
+ env: officialCliEnvironment(process.env),
40
+ stdio: ["pipe", "pipe", "pipe"],
41
+ signal: options.signal,
42
+ killSignal: "SIGKILL",
43
+ });
44
+ let stdout = "", stderr = "", pending = "";
45
+ const seen = new Set();
46
+ const authorizationLines = (chunk, final = false) => {
47
+ pending += chunk;
48
+ const lines = pending.split(/\r?\n/);
49
+ pending = final ? "" : lines.pop() || "";
50
+ for (const line of lines) {
51
+ const url = line.match(/https:\/\/signin\.aliyun\.com\/oauth2\/v1\/auth\?[^\s]+/)?.[0];
52
+ if (url && !seen.has(url)) {
53
+ seen.add(url);
54
+ options.onAuthorization?.(url);
55
+ }
56
+ }
57
+ };
58
+ child.stdout.on("data", (chunk) => {
59
+ stdout += chunk.toString();
60
+ authorizationLines(chunk.toString());
61
+ if (stdout.length > 1024 * 1024)
62
+ child.kill("SIGKILL");
63
+ });
64
+ child.stderr.on("data", (chunk) => {
65
+ stderr += chunk.toString();
66
+ if (stderr.length > 1024 * 1024)
67
+ child.kill("SIGKILL");
68
+ });
69
+ child.stdin.on("error", () => {
70
+ /* process errors are handled below */
71
+ });
72
+ // Region and language already have defaults in the isolated profile.
73
+ child.stdin.end("\n\n");
74
+ child.on("error", (error) => reject(options.signal.aborted
75
+ ? options.signal.reason
76
+ : new CapabilityError(error.code === "ENOENT"
77
+ ? "ALIYUN_CLI_NOT_FOUND"
78
+ : "ALIYUN_CLI_FAILED", error.code === "ENOENT"
79
+ ? "Install the official Alibaba Cloud CLI (aliyun >= 3.3.0), then run rkb login again."
80
+ : "The official Alibaba Cloud CLI could not run.")));
81
+ child.on("close", (code) => {
82
+ authorizationLines("", true);
83
+ if (options.signal.aborted) {
84
+ reject(options.signal.reason);
85
+ return;
86
+ }
87
+ if (code !== 0) {
88
+ // CLI stderr may contain tokens and signed URLs; never relay it verbatim.
89
+ const requestId = stderr.match(/request[_ ]?id["\s:=]+([A-Fa-f0-9-]{16,64})/i)?.[1];
90
+ reject(new CapabilityError("ALIYUN_CLI_FAILED", `Official Alibaba Cloud login failed${requestId ? `; RequestId: ${requestId}` : ""}. Check official-cli application authorization and try rkb login --force.`, requestId));
91
+ return;
92
+ }
93
+ resolve(stdout);
94
+ });
95
+ });
96
+ async function callerIdentity(credentials) {
97
+ const client = new Client(new Config({ endpoint: "sts.aliyuncs.com", ...credentials }));
98
+ const response = await client.callApi(new Params({
99
+ action: "GetCallerIdentity",
100
+ version: "2015-04-01",
101
+ protocol: "HTTPS",
102
+ pathname: "/",
103
+ method: "POST",
104
+ authType: "AK",
105
+ style: "RPC",
106
+ reqBodyType: "formData",
107
+ bodyType: "json",
108
+ }), new OpenApiRequest({}), new RuntimeOptions({
109
+ readTimeout: 15000,
110
+ connectTimeout: 15000,
111
+ autoretry: false,
112
+ maxAttempts: 1,
113
+ }));
114
+ return object(response.body);
115
+ }
116
+ export class OfficialCliSession {
117
+ configFile;
118
+ run;
119
+ request;
120
+ identify;
121
+ constructor(configFile = join(getConfigDir(), "aliyun", "config.json"), run = runOfficialCli, request = fetch, identify = callerIdentity) {
122
+ this.configFile = configFile;
123
+ this.run = run;
124
+ this.request = request;
125
+ this.identify = identify;
126
+ }
127
+ async load() {
128
+ let contents;
129
+ try {
130
+ contents = await readFile(this.configFile, "utf8");
131
+ }
132
+ catch (error) {
133
+ if (error.code === "ENOENT")
134
+ return undefined;
135
+ throw new CapabilityError("ALIYUN_SESSION_INVALID", "Cannot read the external login. Run rkb logout --local, then rkb login.");
136
+ }
137
+ try {
138
+ const config = object(JSON.parse(contents));
139
+ const profile = Array.isArray(config.profiles)
140
+ ? config.profiles.find((p) => p?.name === profileName)
141
+ : undefined;
142
+ if (!profile ||
143
+ profile.mode !== "OAuth" ||
144
+ profile.oauth_site_type !== "CN")
145
+ throw loginRequired();
146
+ return { config, profile: object(profile) };
147
+ }
148
+ catch {
149
+ throw new CapabilityError("ALIYUN_SESSION_INVALID", "The external OAuth profile is invalid. Run rkb logout --local, then rkb login.");
150
+ }
151
+ }
152
+ async save(config, signal) {
153
+ signal.throwIfAborted();
154
+ await mkdir(dirname(this.configFile), { recursive: true, mode: 0o700 });
155
+ const temporary = `${this.configFile}.${randomUUID()}.tmp`;
156
+ try {
157
+ await writeFile(temporary, JSON.stringify(config, null, 2) + "\n", {
158
+ mode: 0o600,
159
+ flag: "wx",
160
+ });
161
+ signal.throwIfAborted();
162
+ await rename(temporary, this.configFile);
163
+ }
164
+ finally {
165
+ await rm(temporary, { force: true });
166
+ }
167
+ }
168
+ async oauth(path, init, signal) {
169
+ let response;
170
+ try {
171
+ response = await this.request(`${oauthEndpoint}/${path}`, {
172
+ ...init,
173
+ method: "POST",
174
+ redirect: "error",
175
+ signal: AbortSignal.any([signal, AbortSignal.timeout(15000)]),
176
+ });
177
+ }
178
+ catch {
179
+ signal.throwIfAborted();
180
+ throw new CapabilityError("ALIYUN_OAUTH_REQUEST_FAILED", "Alibaba Cloud OAuth request failed. Check the network and retry.");
181
+ }
182
+ if (!response.ok)
183
+ throw new CapabilityError("ALIYUN_OAUTH_REJECTED", `Alibaba Cloud rejected credential ${path} (HTTP ${response.status}). Run rkb login --force; official-cli authorization is required.`);
184
+ if (path === "revoke")
185
+ return {};
186
+ try {
187
+ return object(await response.json());
188
+ }
189
+ catch {
190
+ throw new CapabilityError("ALIYUN_INVALID_RESPONSE", "Alibaba Cloud returned an invalid credential response.");
191
+ }
192
+ }
193
+ async get(signal = AbortSignal.timeout(30000)) {
194
+ signal.throwIfAborted();
195
+ const saved = await this.load();
196
+ if (!saved || !saved.profile.oauth_access_token)
197
+ throw loginRequired();
198
+ const { profile, config } = saved;
199
+ const now = Math.floor(Date.now() / 1000);
200
+ const credentials = () => ({
201
+ accessKeyId: required(profile.access_key_id),
202
+ accessKeySecret: required(profile.access_key_secret),
203
+ securityToken: required(profile.sts_token),
204
+ expiresAt: Number(profile.sts_expiration) * 1000,
205
+ });
206
+ if (Number(profile.sts_expiration) > now + 60 &&
207
+ profile.access_key_id &&
208
+ profile.access_key_secret &&
209
+ profile.sts_token)
210
+ return credentials();
211
+ if (!(Number(profile.oauth_access_token_expire) > now + 60)) {
212
+ if (!profile.oauth_refresh_token)
213
+ throw loginRequired();
214
+ const renewed = await this.oauth("token", {
215
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
216
+ body: new URLSearchParams({
217
+ grant_type: "refresh_token",
218
+ refresh_token: required(profile.oauth_refresh_token),
219
+ client_id: officialClientId,
220
+ }),
221
+ }, signal);
222
+ profile.oauth_access_token = required(renewed.access_token);
223
+ if (!(typeof renewed.expires_in === "number" && renewed.expires_in > 0))
224
+ throw new CapabilityError("ALIYUN_INVALID_RESPONSE", "Alibaba Cloud returned an invalid token expiry.");
225
+ profile.oauth_access_token_expire = now + renewed.expires_in;
226
+ if (renewed.refresh_token)
227
+ profile.oauth_refresh_token = required(renewed.refresh_token);
228
+ // Persist refresh-token rotation even if the following STS exchange fails.
229
+ await this.save(config, signal);
230
+ }
231
+ const exchanged = await this.oauth("exchange", {
232
+ headers: {
233
+ Authorization: `Bearer ${required(profile.oauth_access_token)}`,
234
+ "Content-Type": "application/json",
235
+ "User-Agent": "aliyun-cli",
236
+ },
237
+ }, signal);
238
+ // The live exchange endpoint returns PascalCase; also accept camelCase.
239
+ const expiration = Date.parse(required(exchanged.Expiration ?? exchanged.expiration));
240
+ if (!Number.isFinite(expiration) || expiration <= Date.now() + 60000)
241
+ throw new CapabilityError("ALIYUN_INVALID_RESPONSE", "Alibaba Cloud returned expired STS credentials.");
242
+ profile.access_key_id = required(exchanged.AccessKeyId ?? exchanged.accessKeyId);
243
+ profile.access_key_secret = required(exchanged.AccessKeySecret ?? exchanged.accessKeySecret);
244
+ profile.sts_token = required(exchanged.SecurityToken ?? exchanged.securityToken);
245
+ profile.sts_expiration = Math.floor(expiration / 1000);
246
+ await this.save(config, signal);
247
+ return credentials();
248
+ }
249
+ async login(options) {
250
+ const saved = await this.load();
251
+ const reused = !options.force && Boolean(saved?.profile.oauth_access_token);
252
+ if (!reused) {
253
+ const version = await this.run(["version"], { signal: options.signal });
254
+ const match = version.trim().match(/^(\d+)\.(\d+)\.(\d+)$/);
255
+ if (!match ||
256
+ Number(match[1]) < 3 ||
257
+ (Number(match[1]) === 3 && Number(match[2]) < 3))
258
+ throw new CapabilityError("ALIYUN_CLI_VERSION", "Official Alibaba Cloud CLI 3.3.0 or later is required.");
259
+ if (!saved)
260
+ await this.save({
261
+ current: profileName,
262
+ profiles: [
263
+ {
264
+ name: profileName,
265
+ mode: "OAuth",
266
+ oauth_site_type: "CN",
267
+ region_id: "cn-hangzhou",
268
+ output_format: "json",
269
+ language: "en",
270
+ },
271
+ ],
272
+ }, options.signal);
273
+ await this.run([
274
+ "configure",
275
+ "--mode",
276
+ "OAuth",
277
+ "--oauth-site-type",
278
+ "CN",
279
+ "--profile",
280
+ profileName,
281
+ "--config-path",
282
+ this.configFile,
283
+ ], options);
284
+ }
285
+ const credentials = await this.get(options.signal);
286
+ let identity;
287
+ try {
288
+ identity = await this.identify(credentials);
289
+ }
290
+ catch {
291
+ options.signal.throwIfAborted();
292
+ throw new CapabilityError("ALIYUN_IDENTITY_FAILED", "STS identity verification failed. Check the network or run rkb login --force.");
293
+ }
294
+ options.signal.throwIfAborted();
295
+ return {
296
+ identity: {
297
+ subject: required(identity.PrincipalId),
298
+ type: required(identity.IdentityType),
299
+ accountId: required(identity.AccountId),
300
+ userId: required(identity.PrincipalId),
301
+ name: required(identity.Arn),
302
+ },
303
+ expiresAt: new Date(credentials.expiresAt).toISOString(),
304
+ reused,
305
+ };
306
+ }
307
+ async logout(options) {
308
+ options.signal.throwIfAborted();
309
+ const saved = options.local ? undefined : await this.load();
310
+ let revocation = options.local ? "skipped" : "not_needed";
311
+ if (saved) {
312
+ const refresh = saved.profile.oauth_refresh_token;
313
+ if (refresh) {
314
+ await this.oauth("revoke", {
315
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
316
+ body: new URLSearchParams({
317
+ token: required(refresh),
318
+ token_type_hint: "refresh_token",
319
+ client_id: officialClientId,
320
+ }),
321
+ }, options.signal);
322
+ revocation = "confirmed";
323
+ }
324
+ else
325
+ revocation = "no_refresh_token";
326
+ }
327
+ options.signal.throwIfAborted();
328
+ try {
329
+ await rm(this.configFile);
330
+ return { cleared: true, revocation };
331
+ }
332
+ catch (error) {
333
+ if (error.code === "ENOENT")
334
+ return { cleared: false, revocation };
335
+ throw new CapabilityError("ALIYUN_LOGOUT_FAILED", "Cannot remove the external login file. Retry rkb logout --local.");
336
+ }
337
+ }
338
+ }
@@ -0,0 +1,34 @@
1
+ import { getConfigValue } from "../../config/index.js";
2
+ import { runtimeProfile } from "../../runtime/profile.js";
3
+ import { CapabilityError } from "../../core/result.js";
4
+ import { field, requiredString } from "./transport.js";
5
+ export class PopContext {
6
+ api;
7
+ selectedTenant;
8
+ initial;
9
+ constructor(api, selectedTenant = () => String(getConfigValue(runtimeProfile.tenantKey) ?? "").trim()) {
10
+ this.api = api;
11
+ this.selectedTenant = selectedTenant;
12
+ }
13
+ initialState() {
14
+ // No client tenant hint: this must reflect the backend's identity mapping.
15
+ return (this.initial ??= this.api.call("GetInitialState"));
16
+ }
17
+ async mappedTenant() {
18
+ // This branch returns currentTenantCode=null when no tenant hint is supplied.
19
+ // Its tenants array contains exactly the identity-mapped tenant.
20
+ const tenants = field(await this.initialState(), "tenants");
21
+ if (!Array.isArray(tenants) || tenants.length !== 1)
22
+ throw new CapabilityError("POP_TENANT_AMBIGUOUS", "POP did not identify a unique account tenant; pipeline tenant selection needs backend support.");
23
+ return requiredString(tenants[0], "code");
24
+ }
25
+ async tenant() {
26
+ return this.selectedTenant() || (await this.mappedTenant());
27
+ }
28
+ async assertBranchTenant() {
29
+ const mapped = await this.mappedTenant();
30
+ const selected = this.selectedTenant();
31
+ if (selected && mapped !== selected)
32
+ throw new CapabilityError("POP_TENANT_UNSUPPORTED", `This POP backend runs pipelines in tenant '${mapped}', but '${selected}' is selected. Cross-tenant pipelines require backend support.`);
33
+ }
34
+ }
@@ -0,0 +1,7 @@
1
+ import { createTransport } from "./transport.js";
2
+ import { PopContext } from "./context.js";
3
+ import { PopPipelineStarter, createPipelineService } from "./pipeline.js";
4
+ export function createService() {
5
+ const api = createTransport();
6
+ return createPipelineService(new PopPipelineStarter(api, new PopContext(api), "PublishBranch"));
7
+ }
@@ -0,0 +1,31 @@
1
+ import { CapabilityError } from "../../core/result.js";
2
+ import { createTransport, field, requiredString, } from "./transport.js";
3
+ export class PopIdentity {
4
+ api;
5
+ constructor(api) {
6
+ this.api = api;
7
+ }
8
+ async whoami() {
9
+ try {
10
+ const state = await this.api.call("GetInitialState");
11
+ const user = field(state, "userInfo");
12
+ const email = field(user, "email");
13
+ return {
14
+ success: true,
15
+ message: "Credentials validated",
16
+ data: {
17
+ name: requiredString(user, "name"),
18
+ ...(typeof email === "string" ? { email } : {}),
19
+ },
20
+ };
21
+ }
22
+ catch (error) {
23
+ return {
24
+ success: false,
25
+ error: error instanceof Error ? error.message : String(error),
26
+ code: error instanceof CapabilityError ? error.code : "POP_IDENTITY_FAILED",
27
+ };
28
+ }
29
+ }
30
+ }
31
+ export const createService = () => new PopIdentity(createTransport());
@@ -0,0 +1,50 @@
1
+ import { CapabilityError } from "../../core/result.js";
2
+ import { PipelineService } from "../../features/doc/pipeline/service.js";
3
+ import { getGitBranch, getPipelineGitIssues } from "../../lib/git-project.js";
4
+ import { resourceContext } from "../../core/context.js";
5
+ import { field } from "./transport.js";
6
+ export class PopPipelineStarter {
7
+ api;
8
+ context;
9
+ action;
10
+ constructor(api, context, action) {
11
+ this.api = api;
12
+ this.context = context;
13
+ this.action = action;
14
+ }
15
+ async start(input) {
16
+ if (input.workflowInstanceId !== undefined)
17
+ throw new CapabilityError("POP_WORKFLOW_UNSUPPORTED", "This POP backend does not accept WORKFLOW_INSTANCE_ID. Unset it before starting a pipeline.");
18
+ await this.context.assertBranchTenant();
19
+ const result = await this.api.call(this.action, {
20
+ SpaceCode: input.spaceCode,
21
+ BranchName: input.branchName,
22
+ });
23
+ const rawId = field(result, "pipelineInstanceId");
24
+ const pipelineId = typeof rawId === "number" ||
25
+ (typeof rawId === "string" && /^[0-9]+$/.test(rawId))
26
+ ? Number(rawId)
27
+ : NaN;
28
+ if (!Number.isSafeInteger(pipelineId) || pipelineId <= 0)
29
+ throw new CapabilityError("POP_INVALID_RESPONSE", "POP returned an invalid pipelineInstanceId.");
30
+ // A POP endpoint is not a console URL. Do not fabricate or open a pipeline URL.
31
+ return { pipelineId };
32
+ }
33
+ }
34
+ export function createPipelineService(api) {
35
+ const context = resourceContext();
36
+ return new PipelineService(api, {
37
+ getProject: () => {
38
+ const branchName = getGitBranch();
39
+ const spaceCode = context.getSpace();
40
+ return branchName && spaceCode
41
+ ? { tenantCode: "", spaceCode, branchName }
42
+ : null;
43
+ },
44
+ getSpace: context.getSpace,
45
+ requireSpace: true,
46
+ bin: context.bin,
47
+ getGitIssues: getPipelineGitIssues,
48
+ workflowInstanceId: process.env.WORKFLOW_INSTANCE_ID,
49
+ });
50
+ }
@@ -0,0 +1,19 @@
1
+ import { CapabilityError } from "../../core/result.js";
2
+ /** Only the platform's explicit sandbox mode can select environment credentials. */
3
+ export function readSandboxStsCredentials(env = process.env, now = Date.now()) {
4
+ if (env.RKB_POP_AUTH_MODE !== "sandbox-sts")
5
+ return undefined;
6
+ const accessKeyId = env.ALIBABA_CLOUD_ACCESS_KEY_ID?.trim();
7
+ const accessKeySecret = env.ALIBABA_CLOUD_ACCESS_KEY_SECRET?.trim();
8
+ const securityToken = env.ALIBABA_CLOUD_SECURITY_TOKEN?.trim();
9
+ if (!accessKeyId || !accessKeySecret || !securityToken)
10
+ throw new CapabilityError("SANDBOX_STS_MISSING", "Sandbox STS credentials are incomplete. Ask the sandbox platform to supply fresh credentials.");
11
+ // Some AKless providers expose only the tuple. Do not invent its expiration.
12
+ const expiration = env.RKB_POP_STS_EXPIRES_AT;
13
+ const expiresAt = expiration === undefined ? undefined : Date.parse(expiration);
14
+ if (expiresAt !== undefined && !Number.isFinite(expiresAt))
15
+ throw new CapabilityError("SANDBOX_STS_INVALID_EXPIRATION", "Sandbox STS expiration is invalid. Ask the sandbox platform to supply fresh credentials.");
16
+ if (expiresAt !== undefined && expiresAt <= now + 60000)
17
+ throw new CapabilityError("SANDBOX_STS_EXPIRED", "Sandbox STS credentials have expired or are about to expire. Ask the sandbox platform to renew them.");
18
+ return { accessKeyId, accessKeySecret, securityToken, expiresAt };
19
+ }
@@ -0,0 +1,67 @@
1
+ import { SpaceReadService } from "../../features/space/read/service.js";
2
+ import { resourceContext } from "../../core/context.js";
3
+ import { CapabilityError } from "../../core/result.js";
4
+ import { createTransport, field, requiredString, } from "./transport.js";
5
+ import { PopContext } from "./context.js";
6
+ function space(value) {
7
+ const result = {
8
+ code: requiredString(value, "spaceCode"),
9
+ name: requiredString(value, "name"),
10
+ };
11
+ for (const key of [
12
+ "tenantCode",
13
+ "description",
14
+ "defaultBranch",
15
+ "defaultLanguage",
16
+ "domain",
17
+ ]) {
18
+ const item = field(value, key);
19
+ if (typeof item === "string")
20
+ result[key] = item;
21
+ }
22
+ return result;
23
+ }
24
+ export class PopSpaceReader {
25
+ api;
26
+ context;
27
+ constructor(api, context) {
28
+ this.api = api;
29
+ this.context = context;
30
+ }
31
+ async listSpaces() {
32
+ const tenantCode = await this.context.tenant();
33
+ const spaces = [];
34
+ const seen = new Set();
35
+ let nextToken;
36
+ do {
37
+ const page = await this.api.call("ListSpaces", {
38
+ TenantCode: tenantCode,
39
+ MaxResults: 100,
40
+ NextToken: nextToken,
41
+ });
42
+ const items = field(page, "spaces");
43
+ if (!Array.isArray(items))
44
+ throw new CapabilityError("POP_INVALID_RESPONSE", "POP ListSpaces response has no spaces array.");
45
+ spaces.push(...items.map(space));
46
+ const token = field(page, "nextToken");
47
+ if (token !== undefined && token !== null && typeof token !== "string")
48
+ throw new CapabilityError("POP_INVALID_RESPONSE", "POP returned an invalid pagination token.");
49
+ nextToken = typeof token === "string" && token ? token : undefined;
50
+ if (nextToken && seen.has(nextToken))
51
+ throw new CapabilityError("POP_PAGINATION_LOOP", "POP returned a repeated pagination token.");
52
+ if (nextToken)
53
+ seen.add(nextToken);
54
+ } while (nextToken);
55
+ return spaces;
56
+ }
57
+ async getSpace(code) {
58
+ return space(await this.api.call("DescribeSpace", {
59
+ TenantCode: await this.context.tenant(),
60
+ SpaceCode: code,
61
+ }));
62
+ }
63
+ }
64
+ export function createService() {
65
+ const api = createTransport();
66
+ return new SpaceReadService(new PopSpaceReader(api, new PopContext(api)), resourceContext());
67
+ }