@kora-platform/cli 0.8.0-rc6 → 0.8.0-rc8

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.
@@ -96,6 +96,13 @@ export declare function createPlatformApiClient(input: PlatformApiClientInput):
96
96
  }): Promise<{
97
97
  variables: RuntimeVariableRecord[];
98
98
  }>;
99
+ upsertRuntimeVariable(session: CliSessionState, orgId: string, inputData: {
100
+ environment: string;
101
+ name: string;
102
+ value: string;
103
+ }): Promise<{
104
+ variable: RuntimeVariableRecord;
105
+ }>;
99
106
  listOrgSecrets(session: CliSessionState, orgId: string, inputData?: {
100
107
  environment?: string;
101
108
  }): Promise<{
@@ -98,6 +98,15 @@ export function createPlatformApiClient(input) {
98
98
  session
99
99
  });
100
100
  },
101
+ async upsertRuntimeVariable(session, orgId, inputData) {
102
+ const { environment, name, value } = inputData;
103
+ return transport.requestJson({
104
+ body: { value },
105
+ method: "PUT",
106
+ path: withQuery(`/api/v1/orgs/${encodeURIComponent(orgId)}/runtime-variables/${encodeURIComponent(name)}`, { environment }),
107
+ session
108
+ });
109
+ },
101
110
  async listOrgSecrets(session, orgId, inputData = {}) {
102
111
  return transport.requestJson({
103
112
  path: withQuery(`/api/v1/orgs/${encodeURIComponent(orgId)}/secrets`, inputData),
@@ -340,7 +340,7 @@ export interface RuntimeControlRunDetailRecord extends RuntimeControlRunRecord {
340
340
  failureLookupError?: string;
341
341
  historyLength?: number;
342
342
  historySizeBytes?: number;
343
- state?: CliRecord;
343
+ state?: CliRecord | null;
344
344
  stateQueryError?: string;
345
345
  }
346
346
  export interface RuntimeControlRunStateRecord extends CliRecord {
@@ -1,4 +1,5 @@
1
1
  import type { ParsedCommand } from "./runner.js";
2
2
  export declare function readOptionalStringFlag(parsed: ParsedCommand, name: string): string | undefined;
3
3
  export declare function readRequiredStringFlag(parsed: ParsedCommand, name: string): string;
4
+ export declare function readRequiredStringFlagPreservingEmpty(parsed: ParsedCommand, name: string): string;
4
5
  export declare function readOptionalNumberFlag(parsed: ParsedCommand, name: string): number | undefined;
@@ -10,6 +10,13 @@ export function readRequiredStringFlag(parsed, name) {
10
10
  }
11
11
  return value;
12
12
  }
13
+ export function readRequiredStringFlagPreservingEmpty(parsed, name) {
14
+ const value = parsed.flags[name];
15
+ if (typeof value !== "string") {
16
+ throw usageProblem(`Missing required flag --${name}.`, parsed.definition.id);
17
+ }
18
+ return value;
19
+ }
13
20
  export function readOptionalNumberFlag(parsed, name) {
14
21
  const value = parsed.flags[name];
15
22
  return typeof value === "number" ? value : undefined;
@@ -755,7 +755,7 @@ const RAW_CLI_COMMANDS = [
755
755
  requiresActiveOrg: true
756
756
  }),
757
757
  command(["access", "members", "list"], "List organization members.", {
758
- labels: ["read", "access"],
758
+ labels: ["read", "chat-read", "access"],
759
759
  flags: [
760
760
  flag("limit", "Maximum number of members to return.", {
761
761
  acceptsValue: true,
@@ -770,7 +770,7 @@ const RAW_CLI_COMMANDS = [
770
770
  requiresActiveOrg: true
771
771
  }),
772
772
  command(["access", "members", "get"], "Show one organization member.", {
773
- labels: ["read", "access"],
773
+ labels: ["read", "chat-read", "access"],
774
774
  args: [arg("user-id", "User id.")],
775
775
  requiresActiveOrg: true
776
776
  }),
@@ -793,7 +793,7 @@ const RAW_CLI_COMMANDS = [
793
793
  requiresActiveOrg: true
794
794
  }),
795
795
  command(["access", "invites", "list"], "List organization invites.", {
796
- labels: ["read", "access"],
796
+ labels: ["read", "chat-read", "access"],
797
797
  flags: [
798
798
  flag("limit", "Maximum number of invites to return.", {
799
799
  acceptsValue: true,
@@ -808,12 +808,12 @@ const RAW_CLI_COMMANDS = [
808
808
  requiresActiveOrg: true
809
809
  }),
810
810
  command(["access", "invites", "get"], "Show one organization invite.", {
811
- labels: ["read", "access"],
811
+ labels: ["read", "chat-read", "access"],
812
812
  args: [arg("invite-id", "Invite id.")],
813
813
  requiresActiveOrg: true
814
814
  }),
815
815
  command(["access", "invites", "create"], "Create an organization invite.", {
816
- labels: ["write", "access"],
816
+ labels: ["write", "chat-write", "access"],
817
817
  flags: [
818
818
  flag("email", "Invitee email address.", {
819
819
  acceptsValue: true,
@@ -1107,6 +1107,23 @@ const RAW_CLI_COMMANDS = [
1107
1107
  })],
1108
1108
  requiresActiveOrg: true
1109
1109
  }),
1110
+ command(["env", "set"], "Create or update one runtime variable without reading other values.", {
1111
+ labels: ["write", "chat-write", "env"],
1112
+ args: [arg("name", "Variable name.")],
1113
+ flags: [
1114
+ flag("environment", "Target environment.", {
1115
+ acceptsValue: true,
1116
+ required: true,
1117
+ valueType: "string"
1118
+ }),
1119
+ flag("value", "Variable value.", {
1120
+ acceptsValue: true,
1121
+ required: true,
1122
+ valueType: "string"
1123
+ })
1124
+ ],
1125
+ requiresActiveOrg: true
1126
+ }),
1110
1127
  command(["env", "replace"], "Replace the full runtime environment from JSON.", {
1111
1128
  labels: ["destructive", "env"],
1112
1129
  destructive: true,
package/dist/commands.js CHANGED
@@ -3,7 +3,7 @@ import { executeAuthLogin, executeAuthLogout, executeAuthSignup, executeAuthWhoa
3
3
  import { getCliSchema, listCliSchemas } from "./schema-registry.js";
4
4
  import { createPlatformApiClient } from "./api-client.js";
5
5
  import { authProblem, genericProblem, notFoundProblem, usageProblem } from "./cli-errors.js";
6
- import { readOptionalNumberFlag, readOptionalStringFlag, readRequiredStringFlag } from "./command-flags.js";
6
+ import { readOptionalNumberFlag, readOptionalStringFlag, readRequiredStringFlag, readRequiredStringFlagPreservingEmpty } from "./command-flags.js";
7
7
  import { executeArtifactDownload, executeArtifactArchive, executeArtifactInspect, executeArtifactInventory, executeArtifactList, executeArtifactPurge, executeArtifactRead, executeArtifactRestore, executeArtifactUpload } from "./artifact-commands.js";
8
8
  import { executeAudit } from "./audit-commands.js";
9
9
  import { isZipArchivePath, readArchiveBytes, readImportEntries, readJsonInputSpecifier, readWorkspaceTestEntries, readTextInputSpecifier, writeReleaseSourceFiles } from "./files.js";
@@ -164,6 +164,7 @@ export async function executeParsedCommand(parsed, context) {
164
164
  return executeExtensions(parsed, context, api, resolveOrgScope);
165
165
  case "env.list":
166
166
  case "env.get":
167
+ case "env.set":
167
168
  case "env.replace":
168
169
  return executeEnv(parsed, context, api);
169
170
  case "secrets.list":
@@ -1224,6 +1225,29 @@ async function executeEnv(parsed, context, api) {
1224
1225
  meta: { command: parsed.definition.path.join(" "), environment, orgId: org.id }
1225
1226
  };
1226
1227
  }
1228
+ case "env.set": {
1229
+ const environment = readRequiredCliEnvironmentFlag(parsed);
1230
+ const name = readRequiredArg(parsed, "name");
1231
+ announceResolvedEnvironment(parsed, context, environment);
1232
+ const data = await api.upsertRuntimeVariable(session, org.id, {
1233
+ environment,
1234
+ name,
1235
+ value: readRequiredStringFlagPreservingEmpty(parsed, "value")
1236
+ });
1237
+ return {
1238
+ data: {
1239
+ variable: {
1240
+ environmentKey: data.variable.environmentKey,
1241
+ name: data.variable.name,
1242
+ status: "defined",
1243
+ updatedAt: data.variable.updatedAt
1244
+ }
1245
+ },
1246
+ human: renderSuccess(`Saved runtime variable '${data.variable.name}' in ${environment}.`),
1247
+ kind: "authoring_env_set",
1248
+ meta: { command: parsed.definition.path.join(" "), environment, orgId: org.id }
1249
+ };
1250
+ }
1227
1251
  case "env.replace": {
1228
1252
  const environment = readRequiredCliEnvironmentFlag(parsed);
1229
1253
  const body = await readJsonInputSpecifier(String(parsed.flags.file), context.stdin, parsed.definition.id, { flag: "file" });
package/dist/runner.js CHANGED
@@ -232,7 +232,10 @@ function parseInvocation(tokens, commandFilter) {
232
232
  positionals.push(token);
233
233
  continue;
234
234
  }
235
- const [rawName = "", maybeValue] = token.slice(2).split("=", 2);
235
+ const rawFlag = token.slice(2);
236
+ const valueSeparatorIndex = rawFlag.indexOf("=");
237
+ const rawName = valueSeparatorIndex === -1 ? rawFlag : rawFlag.slice(0, valueSeparatorIndex);
238
+ const maybeValue = valueSeparatorIndex === -1 ? undefined : rawFlag.slice(valueSeparatorIndex + 1);
236
239
  const flag = flagDefinitions.get(rawName);
237
240
  if (!flag) {
238
241
  const detail = rawName === "output"
@@ -247,11 +250,16 @@ function parseInvocation(tokens, commandFilter) {
247
250
  flags[rawName] = true;
248
251
  continue;
249
252
  }
250
- const valueSource = maybeValue ?? resolved.remainder[index + 1];
251
- if (!valueSource || valueSource.startsWith("--")) {
252
- throw usageProblem(`Flag '--${rawName}' requires a value.`, definition.path.join(" "));
253
+ let valueSource;
254
+ if (maybeValue !== undefined) {
255
+ valueSource = maybeValue;
253
256
  }
254
- if (maybeValue === undefined) {
257
+ else {
258
+ const nextValue = resolved.remainder[index + 1];
259
+ if (!nextValue || nextValue.startsWith("--")) {
260
+ throw usageProblem(`Flag '--${rawName}' requires a value.`, definition.path.join(" "));
261
+ }
262
+ valueSource = nextValue;
255
263
  index += 1;
256
264
  }
257
265
  const coercedValue = coerceFlagValue(flag.valueType ?? "string", valueSource, rawName, definition.path.join(" "));
@@ -1,5 +1,10 @@
1
+ import { mkdir, open, rm, stat } from "node:fs/promises";
2
+ import { dirname } from "node:path";
1
3
  import { readSessionFile, removeSessionFile, writeSessionFile } from "./session.js";
4
+ const REFRESH_LOCK_STALE_MS = 30_000;
5
+ const REFRESH_LOCK_RETRY_MS = 25;
2
6
  export function createFileSessionStore(path) {
7
+ const refreshLockPath = `${path}.refresh.lock`;
3
8
  return {
4
9
  async clear() {
5
10
  await removeSessionFile(path);
@@ -9,11 +14,15 @@ export function createFileSessionStore(path) {
9
14
  },
10
15
  async write(session) {
11
16
  await writeSessionFile(path, session);
17
+ },
18
+ async withRefreshLock(operation) {
19
+ return withFileRefreshLock(refreshLockPath, operation);
12
20
  }
13
21
  };
14
22
  }
15
23
  export function createMemorySessionStore(initial = null) {
16
24
  let current = initial;
25
+ let refreshLock = Promise.resolve();
17
26
  return {
18
27
  async clear() {
19
28
  current = null;
@@ -23,6 +32,77 @@ export function createMemorySessionStore(initial = null) {
23
32
  },
24
33
  async write(session) {
25
34
  current = session;
35
+ },
36
+ async withRefreshLock(operation) {
37
+ const previous = refreshLock;
38
+ let release;
39
+ refreshLock = new Promise((resolve) => {
40
+ release = resolve;
41
+ });
42
+ await previous;
43
+ try {
44
+ return await operation();
45
+ }
46
+ finally {
47
+ release();
48
+ }
26
49
  }
27
50
  };
28
51
  }
52
+ async function withFileRefreshLock(lockPath, operation) {
53
+ const release = await acquireFileRefreshLock(lockPath);
54
+ try {
55
+ return await operation();
56
+ }
57
+ finally {
58
+ await release();
59
+ }
60
+ }
61
+ async function acquireFileRefreshLock(lockPath) {
62
+ await mkdir(dirname(lockPath), { recursive: true });
63
+ while (true) {
64
+ try {
65
+ const handle = await open(lockPath, "wx", 0o600);
66
+ try {
67
+ await handle.writeFile(`${String(process.pid)} ${new Date().toISOString()}\n`);
68
+ }
69
+ finally {
70
+ await handle.close();
71
+ }
72
+ return async () => {
73
+ await rm(lockPath, { force: true });
74
+ };
75
+ }
76
+ catch (error) {
77
+ if (!isFileExistsError(error)) {
78
+ throw error;
79
+ }
80
+ await removeStaleFileRefreshLock(lockPath);
81
+ await sleep(REFRESH_LOCK_RETRY_MS);
82
+ }
83
+ }
84
+ }
85
+ async function removeStaleFileRefreshLock(lockPath) {
86
+ try {
87
+ const fileStat = await stat(lockPath);
88
+ if (Date.now() - fileStat.mtimeMs > REFRESH_LOCK_STALE_MS) {
89
+ await rm(lockPath, { force: true });
90
+ }
91
+ }
92
+ catch (error) {
93
+ if (!isMissingFileError(error)) {
94
+ throw error;
95
+ }
96
+ }
97
+ }
98
+ async function sleep(ms) {
99
+ await new Promise((resolve) => {
100
+ setTimeout(resolve, ms);
101
+ });
102
+ }
103
+ function isFileExistsError(error) {
104
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "EEXIST");
105
+ }
106
+ function isMissingFileError(error) {
107
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
108
+ }
@@ -0,0 +1,10 @@
1
+ import type { CliSessionState } from "./session.js";
2
+ import type { SessionStore } from "./transport.js";
3
+ export declare function refreshSessionWithCoordination(input: {
4
+ isRefreshTokenInvalidError: (error: unknown) => boolean;
5
+ refreshSessionDirect: (session: CliSessionState) => Promise<CliSessionState>;
6
+ session: CliSessionState;
7
+ sessionStore: SessionStore;
8
+ shouldRefresh: (session: CliSessionState) => boolean;
9
+ }): Promise<CliSessionState>;
10
+ export declare function resolveEffectiveSession(requestSession: CliSessionState | null, sessionStore: SessionStore): Promise<CliSessionState | null>;
@@ -0,0 +1,51 @@
1
+ export async function refreshSessionWithCoordination(input) {
2
+ return withRefreshLock(input.sessionStore, async () => {
3
+ const effectiveSession = (await resolveEffectiveSession(input.session, input.sessionStore)) ?? input.session;
4
+ if (isSessionNewer(effectiveSession, input.session) && !input.shouldRefresh(effectiveSession)) {
5
+ return effectiveSession;
6
+ }
7
+ try {
8
+ return await input.refreshSessionDirect(effectiveSession);
9
+ }
10
+ catch (error) {
11
+ if (input.isRefreshTokenInvalidError(error)) {
12
+ const recoveredSession = await resolveEffectiveSession(input.session, input.sessionStore);
13
+ if (recoveredSession &&
14
+ isSessionNewer(recoveredSession, input.session) &&
15
+ !input.shouldRefresh(recoveredSession)) {
16
+ return recoveredSession;
17
+ }
18
+ }
19
+ throw error;
20
+ }
21
+ });
22
+ }
23
+ async function withRefreshLock(sessionStore, operation) {
24
+ return sessionStore.withRefreshLock
25
+ ? sessionStore.withRefreshLock(operation)
26
+ : operation();
27
+ }
28
+ export async function resolveEffectiveSession(requestSession, sessionStore) {
29
+ if (!requestSession) {
30
+ return null;
31
+ }
32
+ const storedSession = await sessionStore.read();
33
+ if (!storedSession || !isSameSessionIdentity(storedSession, requestSession)) {
34
+ return requestSession;
35
+ }
36
+ return isSessionNewer(storedSession, requestSession) ? storedSession : requestSession;
37
+ }
38
+ function isSameSessionIdentity(candidate, current) {
39
+ return candidate.baseUrl === current.baseUrl
40
+ && candidate.user.id === current.user.id
41
+ && candidate.accessTokenPayload.sub === current.accessTokenPayload.sub;
42
+ }
43
+ function isSessionNewer(candidate, current) {
44
+ if (candidate.accessToken === current.accessToken && candidate.refreshToken === current.refreshToken) {
45
+ return false;
46
+ }
47
+ if (candidate.accessTokenPayload.exp !== current.accessTokenPayload.exp) {
48
+ return candidate.accessTokenPayload.exp > current.accessTokenPayload.exp;
49
+ }
50
+ return candidate.accessTokenPayload.iat >= current.accessTokenPayload.iat;
51
+ }
@@ -2,6 +2,7 @@ import { type CliSessionState } from "./session.js";
2
2
  export interface SessionStore {
3
3
  clear(): Promise<void>;
4
4
  read(): Promise<CliSessionState | null>;
5
+ withRefreshLock?<T>(operation: () => Promise<T>): Promise<T>;
5
6
  write(session: CliSessionState): Promise<void>;
6
7
  }
7
8
  export interface CliAuthSettings {
package/dist/transport.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { normalizePublicErrorCode, readPublicErrorCodeFromType } from "./error-code.js";
2
2
  import { extractRefreshTokenFromHeaders } from "./session.js";
3
+ import { refreshSessionWithCoordination, resolveEffectiveSession } from "./transport-refresh.js";
3
4
  export class ApiError extends Error {
4
5
  code;
5
6
  detail;
@@ -28,6 +29,13 @@ export class ApiError extends Error {
28
29
  }
29
30
  export function createPlatformTransport(input) {
30
31
  const now = input.now ?? Date.now;
32
+ const refreshSession = async (session) => refreshSessionWithCoordination({
33
+ isRefreshTokenInvalidError,
34
+ refreshSessionDirect: async (effectiveSession) => refreshSessionDirect(input.sessionStore, effectiveSession),
35
+ session,
36
+ sessionStore: input.sessionStore,
37
+ shouldRefresh: (effectiveSession) => shouldRefresh(effectiveSession, now)
38
+ });
31
39
  return {
32
40
  async getAuthSettings(baseUrl) {
33
41
  const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
@@ -116,18 +124,7 @@ export function createPlatformTransport(input) {
116
124
  return { session, status: "approved" };
117
125
  },
118
126
  async refreshSession(session) {
119
- const response = await fetch(joinBaseUrl(session.baseUrl, "/api/v1/auth/refresh"), {
120
- body: JSON.stringify({
121
- refreshToken: session.refreshToken
122
- }),
123
- headers: {
124
- "content-type": "application/json"
125
- },
126
- method: "POST"
127
- });
128
- const nextSession = await parseSessionResponse(response, session.baseUrl, "/api/v1/auth/refresh", session.activeOrg);
129
- await input.sessionStore.write(nextSession);
130
- return nextSession;
127
+ return refreshSession(session);
131
128
  },
132
129
  async requestJson(request) {
133
130
  let session = await resolveEffectiveSession(request.session, input.sessionStore);
@@ -141,11 +138,11 @@ export function createPlatformTransport(input) {
141
138
  });
142
139
  }
143
140
  if (shouldRefresh(session, now) && canRefresh(session)) {
144
- session = await this.refreshSession(session);
141
+ session = await refreshSession(session);
145
142
  }
146
143
  const firstResponse = await authenticatedFetch(session, request);
147
144
  if (firstResponse.status === 401 && canRefresh(session)) {
148
- session = await this.refreshSession(session);
145
+ session = await refreshSession(session);
149
146
  return handleJsonResponse(await authenticatedFetch(session, request), request.path);
150
147
  }
151
148
  return handleJsonResponse(firstResponse, request.path);
@@ -162,17 +159,36 @@ export function createPlatformTransport(input) {
162
159
  });
163
160
  }
164
161
  if (shouldRefresh(session, now) && canRefresh(session)) {
165
- session = await this.refreshSession(session);
162
+ session = await refreshSession(session);
166
163
  }
167
164
  const firstResponse = await authenticatedFetch(session, request);
168
165
  if (firstResponse.status === 401 && canRefresh(session)) {
169
- session = await this.refreshSession(session);
166
+ session = await refreshSession(session);
170
167
  return handleBytesResponse(await authenticatedFetch(session, request), request);
171
168
  }
172
169
  return handleBytesResponse(firstResponse, request);
173
170
  }
174
171
  };
175
172
  }
173
+ async function refreshSessionDirect(sessionStore, session) {
174
+ const response = await fetch(joinBaseUrl(session.baseUrl, "/api/v1/auth/refresh"), {
175
+ body: JSON.stringify({
176
+ refreshToken: session.refreshToken
177
+ }),
178
+ headers: {
179
+ "content-type": "application/json"
180
+ },
181
+ method: "POST"
182
+ });
183
+ const nextSession = await parseSessionResponse(response, session.baseUrl, "/api/v1/auth/refresh", session.activeOrg);
184
+ await sessionStore.write(nextSession);
185
+ return nextSession;
186
+ }
187
+ function isRefreshTokenInvalidError(error) {
188
+ return error instanceof ApiError &&
189
+ error.code === "auth/token_invalid" &&
190
+ error.instance === "/api/v1/auth/refresh";
191
+ }
176
192
  async function authenticatedFetch(session, request) {
177
193
  const body = resolveRequestBody(request.body, session);
178
194
  const hasJsonBody = body !== undefined;
@@ -389,27 +405,3 @@ function shouldRefresh(session, now) {
389
405
  function canRefresh(session) {
390
406
  return session.refreshToken.trim().length > 0;
391
407
  }
392
- async function resolveEffectiveSession(requestSession, sessionStore) {
393
- if (!requestSession) {
394
- return null;
395
- }
396
- const storedSession = await sessionStore.read();
397
- if (!storedSession || !isSameSessionIdentity(storedSession, requestSession)) {
398
- return requestSession;
399
- }
400
- return isSessionNewer(storedSession, requestSession) ? storedSession : requestSession;
401
- }
402
- function isSameSessionIdentity(candidate, current) {
403
- return candidate.baseUrl === current.baseUrl
404
- && candidate.user.id === current.user.id
405
- && candidate.accessTokenPayload.sub === current.accessTokenPayload.sub;
406
- }
407
- function isSessionNewer(candidate, current) {
408
- if (candidate.accessToken === current.accessToken && candidate.refreshToken === current.refreshToken) {
409
- return false;
410
- }
411
- if (candidate.accessTokenPayload.exp !== current.accessTokenPayload.exp) {
412
- return candidate.accessTokenPayload.exp > current.accessTokenPayload.exp;
413
- }
414
- return candidate.accessTokenPayload.iat >= current.accessTokenPayload.iat;
415
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kora-platform/cli",
3
- "version": "0.8.0-rc6",
3
+ "version": "0.8.0-rc8",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "dist/library.js",