@kora-platform/cli 0.8.0-rc3 → 0.8.0-rc7

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/dist/files.js CHANGED
@@ -7,14 +7,38 @@ const MAX_IMPORT_FILE_COUNT = 500;
7
7
  const MAX_IMPORT_FILE_BYTES = 1_000_000;
8
8
  const MAX_PACKAGE_FILE_BYTES = 2_000_000;
9
9
  const MAX_IMPORT_TOTAL_BYTES = 10_000_000;
10
- export async function readJsonInputSpecifier(specifier, stdin, instance) {
10
+ export async function readJsonInputSpecifier(specifier, stdin, instance, options = {}) {
11
11
  if (specifier === "-") {
12
- return readJsonObject(await readStream(stdin), instance);
12
+ return readJsonObject(await readStream(stdin), instance, {
13
+ source: "stdin",
14
+ ...options
15
+ });
13
16
  }
14
17
  if (!specifier.startsWith("@")) {
15
- throw usageProblem("Structured JSON input must use @file.json or - for stdin.", instance);
18
+ throw usageProblem("Structured JSON input must use @file.json or - for stdin.", instance, {
19
+ ...(options.flag ? { flag: options.flag } : {})
20
+ });
21
+ }
22
+ const filePath = resolve(specifier.slice(1));
23
+ let source;
24
+ try {
25
+ source = await readFile(filePath, "utf8");
26
+ }
27
+ catch (error) {
28
+ const nativeCode = readNodeErrorCode(error);
29
+ if (nativeCode && isLocalPathReadErrorCode(nativeCode)) {
30
+ throw usageProblem(`Structured JSON input file ${filePath} could not be read: ${nativeCode}.`, instance, {
31
+ filePath,
32
+ ...(options.flag ? { flag: options.flag } : {}),
33
+ nativeCode
34
+ });
35
+ }
36
+ throw error;
16
37
  }
17
- return readJsonObject(await readFile(specifier.slice(1), "utf8"), instance);
38
+ return readJsonObject(source, instance, {
39
+ filePath,
40
+ ...options
41
+ });
18
42
  }
19
43
  export async function readTextInputSpecifier(specifier, stdin, instance) {
20
44
  if (specifier === "-") {
@@ -35,12 +59,26 @@ export function isZipArchivePath(pathValue) {
35
59
  return extname(pathValue).toLowerCase() === ".zip";
36
60
  }
37
61
  export async function readArchiveBytes(pathValue, instance) {
62
+ const file = await readLocalFileBytes(pathValue, instance, {
63
+ regularFileMessage: "Archive path must be a regular .zip file, not a directory or symbolic link."
64
+ });
65
+ return file.bytes;
66
+ }
67
+ export async function readLocalFileBytes(pathValue, instance, options = {}) {
38
68
  const absolutePath = resolve(pathValue);
39
- const pathStat = await lstat(absolutePath);
69
+ const pathStat = await readLocalPathStat(absolutePath, instance);
40
70
  if (pathStat.isSymbolicLink() || !pathStat.isFile()) {
41
- throw usageProblem("Archive path must be a regular .zip file, not a directory or symbolic link.", instance);
71
+ throw usageProblem(options.regularFileMessage ?? "Path must be a regular file, not a directory or symbolic link.", instance);
72
+ }
73
+ try {
74
+ return {
75
+ absolutePath,
76
+ bytes: await readFile(absolutePath)
77
+ };
78
+ }
79
+ catch (error) {
80
+ throwLocalPathReadProblem(error, absolutePath, instance);
42
81
  }
43
- return await readFile(absolutePath);
44
82
  }
45
83
  export async function readWorkspaceTestEntries(pathValue) {
46
84
  return await readUtf8Entries(pathValue, {
@@ -50,7 +88,7 @@ export async function readWorkspaceTestEntries(pathValue) {
50
88
  }
51
89
  async function readUtf8Entries(pathValue, options) {
52
90
  const absolutePath = resolve(pathValue);
53
- const pathStat = await lstat(absolutePath);
91
+ const pathStat = await readLocalPathStat(absolutePath, options.instance);
54
92
  if (pathStat.isSymbolicLink()) {
55
93
  throw usageProblem("Import path must be a regular file or directory, not a symbolic link.", options.instance);
56
94
  }
@@ -151,7 +189,7 @@ export async function writePackageExport(outPath, envelope) {
151
189
  }
152
190
  export async function readPackageFileEntries(pathValue, instance) {
153
191
  const absolutePath = resolve(pathValue);
154
- const pathStat = await lstat(absolutePath);
192
+ const pathStat = await readLocalPathStat(absolutePath, instance);
155
193
  if (pathStat.isSymbolicLink()) {
156
194
  throw usageProblem("Package path must be a regular file or directory, not a symbolic link.", instance);
157
195
  }
@@ -161,7 +199,9 @@ export async function readPackageFileEntries(pathValue, instance) {
161
199
  totalBytes: 0
162
200
  }, instance);
163
201
  return [{
164
- contentBase64: (await readFile(absolutePath)).toString("base64"),
202
+ contentBase64: (await readLocalFileBytes(absolutePath, instance, {
203
+ regularFileMessage: "Package path must be a regular file or directory, not a symbolic link."
204
+ })).bytes.toString("base64"),
165
205
  path: basename(absolutePath)
166
206
  }];
167
207
  }
@@ -173,16 +213,24 @@ export async function readPackageFileEntries(pathValue, instance) {
173
213
  entries.sort((left, right) => left.path.localeCompare(right.path));
174
214
  return entries;
175
215
  }
176
- function readJsonObject(source, instance) {
216
+ function readJsonObject(source, instance, options) {
177
217
  let parsed;
178
218
  try {
179
219
  parsed = JSON.parse(source);
180
220
  }
181
221
  catch {
182
- throw usageProblem("Structured JSON input must be valid JSON.", instance);
222
+ const sourceLabel = "filePath" in options ? `file ${options.filePath}` : "from stdin";
223
+ throw usageProblem(`Structured JSON input ${sourceLabel} must be valid JSON.`, instance, {
224
+ ...("filePath" in options ? { filePath: options.filePath } : { source: options.source }),
225
+ ...(options.flag ? { flag: options.flag } : {})
226
+ });
183
227
  }
184
228
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
185
- throw usageProblem("Structured JSON input must be a JSON object.", instance);
229
+ const sourceLabel = "filePath" in options ? `file ${options.filePath}` : "from stdin";
230
+ throw usageProblem(`Structured JSON input ${sourceLabel} must be a JSON object.`, instance, {
231
+ ...("filePath" in options ? { filePath: options.filePath } : { source: options.source }),
232
+ ...(options.flag ? { flag: options.flag } : {})
233
+ });
186
234
  }
187
235
  return parsed;
188
236
  }
@@ -232,10 +280,10 @@ async function walkPackageDirectory(root, current, entries, counters, instance)
232
280
  if (!child.isFile()) {
233
281
  continue;
234
282
  }
235
- const childStat = await lstat(childPath);
283
+ const childStat = await readLocalPathStat(childPath, instance);
236
284
  assertPackageFileCanBeRead(childStat.size, relativePath, counters, instance);
237
285
  entries.push({
238
- contentBase64: (await readFile(childPath)).toString("base64"),
286
+ contentBase64: (await readLocalFileBytes(childPath, instance)).bytes.toString("base64"),
239
287
  path: relativePath
240
288
  });
241
289
  }
@@ -289,7 +337,13 @@ async function readLimitedUtf8File(filePath, displayPath, counters, options) {
289
337
  if (counters.fileCount >= MAX_IMPORT_FILE_COUNT) {
290
338
  throw usageProblem(`Import has more than ${String(MAX_IMPORT_FILE_COUNT)} files.`, options.instance);
291
339
  }
292
- const content = await readFile(filePath, "utf8");
340
+ let content;
341
+ try {
342
+ content = await readFile(filePath, "utf8");
343
+ }
344
+ catch (error) {
345
+ throwLocalPathReadProblem(error, filePath, options.instance);
346
+ }
293
347
  const bytes = Buffer.byteLength(content, "utf8");
294
348
  if (bytes > options.maxFileBytes) {
295
349
  throw usageProblem(`Import file ${displayPath} exceeds the per-file byte limit.`, options.instance);
@@ -320,3 +374,35 @@ function isNodeErrorWithCode(error, code) {
320
374
  "code" in error &&
321
375
  error.code === code;
322
376
  }
377
+ function readNodeErrorCode(error) {
378
+ if (typeof error !== "object" || error === null || !("code" in error)) {
379
+ return undefined;
380
+ }
381
+ const code = error.code;
382
+ return typeof code === "string" && code.length > 0 ? code : undefined;
383
+ }
384
+ function isLocalPathReadErrorCode(code) {
385
+ return code === "EACCES" ||
386
+ code === "EISDIR" ||
387
+ code === "ENOENT" ||
388
+ code === "ENOTDIR" ||
389
+ code === "EPERM";
390
+ }
391
+ async function readLocalPathStat(absolutePath, instance) {
392
+ try {
393
+ return await lstat(absolutePath);
394
+ }
395
+ catch (error) {
396
+ throwLocalPathReadProblem(error, absolutePath, instance);
397
+ }
398
+ }
399
+ function throwLocalPathReadProblem(error, absolutePath, instance) {
400
+ const nativeCode = readNodeErrorCode(error);
401
+ if (nativeCode && isLocalPathReadErrorCode(nativeCode)) {
402
+ throw usageProblem(`Local path ${absolutePath} could not be read: ${nativeCode}.`, instance, {
403
+ nativeCode,
404
+ path: absolutePath
405
+ });
406
+ }
407
+ throw error;
408
+ }
package/dist/format.d.ts CHANGED
@@ -10,6 +10,7 @@ export declare function renderJsonEnvelope(input: {
10
10
  meta?: Record<string, unknown>;
11
11
  }): string;
12
12
  export declare function renderProblemJson(input: {
13
+ code: string;
13
14
  detail: string;
14
15
  details?: Record<string, unknown>;
15
16
  instance: string;
package/dist/format.js CHANGED
@@ -7,12 +7,17 @@ export function renderJsonEnvelope(input) {
7
7
  }
8
8
  export function renderProblemJson(input) {
9
9
  return `${JSON.stringify({
10
- detail: input.detail,
11
- ...(input.details ? { details: input.details } : {}),
12
- instance: input.instance,
13
- status: input.status,
14
- title: input.title,
15
- type: input.type
10
+ error: {
11
+ code: input.code,
12
+ details: {
13
+ ...(input.details ?? {}),
14
+ instance: input.instance,
15
+ status: input.status,
16
+ title: input.title,
17
+ type: input.type
18
+ },
19
+ message: input.detail
20
+ }
16
21
  }, null, 2)}\n`;
17
22
  }
18
23
  export function renderTable(rows, columns) {
package/dist/runner.js CHANGED
@@ -89,6 +89,7 @@ export async function runCli(argv, input = {}) {
89
89
  : `${problem.title}: ${formatProblemDetail(problem.detail, details)}`}\n`,
90
90
  stdout: wantsJson
91
91
  ? renderProblemJson({
92
+ code: problem.code,
92
93
  detail: problem.detail,
93
94
  ...(details ? { details } : {}),
94
95
  instance: problem.instance,
@@ -231,7 +232,10 @@ function parseInvocation(tokens, commandFilter) {
231
232
  positionals.push(token);
232
233
  continue;
233
234
  }
234
- 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);
235
239
  const flag = flagDefinitions.get(rawName);
236
240
  if (!flag) {
237
241
  const detail = rawName === "output"
@@ -246,11 +250,16 @@ function parseInvocation(tokens, commandFilter) {
246
250
  flags[rawName] = true;
247
251
  continue;
248
252
  }
249
- const valueSource = maybeValue ?? resolved.remainder[index + 1];
250
- if (!valueSource || valueSource.startsWith("--")) {
251
- throw usageProblem(`Flag '--${rawName}' requires a value.`, definition.path.join(" "));
253
+ let valueSource;
254
+ if (maybeValue !== undefined) {
255
+ valueSource = maybeValue;
252
256
  }
253
- 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;
254
263
  index += 1;
255
264
  }
256
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 {
@@ -10,6 +11,19 @@ export interface CliAuthSettings {
10
11
  oidcEnabled: boolean;
11
12
  selfServiceOrgCreationEnabled: boolean;
12
13
  }
14
+ export interface CliDeviceLoginStart {
15
+ deviceCode: string;
16
+ expiresAt: string;
17
+ pollIntervalSeconds: number;
18
+ userCode: string;
19
+ verificationPath: string;
20
+ }
21
+ export type CliDeviceLoginClaim = {
22
+ session: CliSessionState;
23
+ status: "approved";
24
+ } | {
25
+ status: "denied" | "expired" | "pending";
26
+ };
13
27
  export type ApiErrorDetails = Record<string, unknown>;
14
28
  type RequestBody = unknown | ((session: CliSessionState) => unknown);
15
29
  type BytesRequest = {
@@ -26,6 +40,7 @@ type BytesRequest = {
26
40
  validateHeaders?: (headers: Headers) => void;
27
41
  };
28
42
  export declare class ApiError extends Error {
43
+ readonly code: string;
29
44
  readonly detail: string;
30
45
  readonly details?: ApiErrorDetails;
31
46
  readonly instance: string;
@@ -34,6 +49,7 @@ export declare class ApiError extends Error {
34
49
  readonly title: string;
35
50
  readonly type: string;
36
51
  constructor(input: {
52
+ code?: string;
37
53
  detail: string;
38
54
  details?: ApiErrorDetails;
39
55
  instance: string;
@@ -59,6 +75,11 @@ export declare function createPlatformTransport(input: {
59
75
  name: string;
60
76
  password: string;
61
77
  }): Promise<CliSessionState>;
78
+ startDeviceLogin(baseUrl: string): Promise<CliDeviceLoginStart>;
79
+ claimDeviceLogin(claim: {
80
+ baseUrl: string;
81
+ deviceCode: string;
82
+ }): Promise<CliDeviceLoginClaim>;
62
83
  refreshSession(session: CliSessionState): Promise<CliSessionState>;
63
84
  requestJson<T>(request: {
64
85
  body?: RequestBody;