@ultikits/cli 1.2.0 → 2.0.0

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.
@@ -1,13 +1,58 @@
1
- import type { Credentials, ApiResponse } from '../types.js';
1
+ import type { CreateModuleBody, CreateVersionBody, CreateVersionResult, DeveloperModule, DeviceAuthorization, DeviceTokenResult, DevModuleVersion, ModuleDeletion, ModuleListItem, Page, PublicModuleVersion, UpdateModuleBody, UploadTicket, WithdrawnVersion, Whoami } from '../types.js';
2
+ export declare class ApiError extends Error {
3
+ readonly status: number;
4
+ readonly code: string;
5
+ readonly requestId?: string | undefined;
6
+ readonly details?: Record<string, any> | undefined;
7
+ constructor(status: number, code: string, message: string, requestId?: string | undefined, details?: Record<string, any> | undefined);
8
+ /** Seconds to wait from details.retryAfterSeconds or the Retry-After header. */
9
+ get retryAfterSeconds(): number | undefined;
10
+ }
11
+ export interface ApiClientOptions {
12
+ apiUrl: string;
13
+ /** ucd_ or ucc_; omitted for the public device-authorization routes. */
14
+ credential?: string | null;
15
+ }
2
16
  export declare class ApiClient {
3
- private baseUrl;
4
- private credentials;
5
- constructor(credentials: Credentials);
6
- static fromEnvOrCredentials(credentials: Credentials | null): ApiClient | null;
7
- private getHeaders;
8
- get<T = any>(path: string): Promise<ApiResponse<T>>;
9
- post<T = any>(path: string, body?: any): Promise<ApiResponse<T>>;
10
- postMultipart<T = any>(path: string, formData: FormData, extraHeaders?: Record<string, string>): Promise<ApiResponse<T>>;
11
- put<T = any>(path: string, body?: any): Promise<ApiResponse<T>>;
12
- delete<T = any>(path: string): Promise<ApiResponse<T>>;
17
+ readonly apiUrl: string;
18
+ private readonly credential;
19
+ constructor(options: ApiClientOptions);
20
+ request<T>(method: string, path: string, body?: unknown, opts?: {
21
+ auth?: boolean;
22
+ }): Promise<T>;
23
+ deviceAuthorize(suggestedName?: string): Promise<DeviceAuthorization>;
24
+ deviceToken(deviceCode: string): Promise<DeviceTokenResult>;
25
+ whoami(): Promise<Whoami>;
26
+ revokeCurrentCredential(): Promise<{
27
+ ok: true;
28
+ }>;
29
+ createModule(body: CreateModuleBody): Promise<DeveloperModule>;
30
+ listModules(query: {
31
+ page: number;
32
+ pageSize: number;
33
+ q?: string;
34
+ }): Promise<Page<ModuleListItem>>;
35
+ updateModule(moduleRef: string, body: UpdateModuleBody): Promise<ModuleListItem>;
36
+ /** Recoverable delete: the module can be restored until `restorableUntil`. */
37
+ deleteModule(moduleRef: string): Promise<ModuleDeletion>;
38
+ restoreModule(moduleRef: string): Promise<ModuleListItem>;
39
+ /** Irreversible: the version stays as a read-only record and its storage is returned. */
40
+ withdrawVersion(moduleRef: string, version: string): Promise<{
41
+ version: WithdrawnVersion;
42
+ }>;
43
+ /** Public catalogue route: never sends the credential, even when one is configured. */
44
+ listPublicVersions(moduleRef: string, query: {
45
+ page: number;
46
+ pageSize: number;
47
+ includeWithdrawn: boolean;
48
+ }): Promise<Page<PublicModuleVersion>>;
49
+ createVersion(moduleRef: string, body: CreateVersionBody): Promise<CreateVersionResult>;
50
+ requestUploadToken(moduleRef: string, version: string): Promise<UploadTicket>;
51
+ /**
52
+ * PUTs the JAR to the ticket URL. The ticket authenticates the upload, so no
53
+ * Authorization header is sent, and the URL must be on the configured API origin.
54
+ */
55
+ uploadJar(ticket: UploadTicket, bytes: Uint8Array): Promise<{
56
+ version: DevModuleVersion;
57
+ }>;
13
58
  }
@@ -1,83 +1,157 @@
1
+ // Client for the UltiCloud machine API (contract: developer-client 1.0.0, copied
2
+ // under tests/contract/). Success bodies are { data }, errors { error: { code, ... } }.
3
+ import { assertAllowedApiUrl } from './config.js';
4
+ export class ApiError extends Error {
5
+ status;
6
+ code;
7
+ requestId;
8
+ details;
9
+ constructor(status, code, message, requestId, details) {
10
+ super(message);
11
+ this.status = status;
12
+ this.code = code;
13
+ this.requestId = requestId;
14
+ this.details = details;
15
+ this.name = 'ApiError';
16
+ }
17
+ /** Seconds to wait from details.retryAfterSeconds or the Retry-After header. */
18
+ get retryAfterSeconds() {
19
+ const value = this.details?.retryAfterSeconds;
20
+ return typeof value === 'number' ? value : undefined;
21
+ }
22
+ }
1
23
  export class ApiClient {
2
- baseUrl;
3
- credentials;
4
- constructor(credentials) {
5
- this.credentials = credentials;
6
- this.baseUrl = credentials.api_url;
7
- }
8
- static fromEnvOrCredentials(credentials) {
9
- const envToken = process.env.ULTIKITS_TOKEN;
10
- if (envToken) {
11
- return new ApiClient({
12
- type: 'access_token',
13
- token: envToken,
14
- api_url: 'https://api.ultikits.com',
15
- });
16
- }
17
- if (credentials) {
18
- return new ApiClient(credentials);
19
- }
20
- return null;
24
+ apiUrl;
25
+ credential;
26
+ constructor(options) {
27
+ this.apiUrl = assertAllowedApiUrl(options.apiUrl);
28
+ this.credential = options.credential ?? null;
21
29
  }
22
- getHeaders() {
23
- const headers = {};
24
- if (this.credentials.type === 'oauth') {
25
- headers['Authorization'] = `Bearer ${this.credentials.access_token}`;
26
- }
27
- else {
28
- headers['X-Access-Token'] = this.credentials.token;
30
+ async request(method, path, body, opts = {}) {
31
+ const headers = { accept: 'application/json' };
32
+ if (opts.auth !== false) {
33
+ if (!this.credential)
34
+ throw new Error('No credential configured');
35
+ headers.authorization = `Bearer ${this.credential}`;
29
36
  }
30
- return headers;
31
- }
32
- async get(path) {
33
- const response = await fetch(`${this.baseUrl}${path}`, {
34
- method: 'GET',
35
- headers: this.getHeaders(),
36
- });
37
- return response.json();
38
- }
39
- async post(path, body) {
40
- const headers = {
41
- ...this.getHeaders(),
42
- 'Content-Type': 'application/json',
43
- };
44
- const response = await fetch(`${this.baseUrl}${path}`, {
45
- method: 'POST',
37
+ if (body !== undefined)
38
+ headers['content-type'] = 'application/json';
39
+ const response = await fetch(`${this.apiUrl}${path}`, {
40
+ method,
46
41
  headers,
47
- body: body ? JSON.stringify(body) : undefined,
42
+ body: body === undefined ? undefined : JSON.stringify(body),
43
+ redirect: 'error',
48
44
  });
49
- return response.json();
50
- }
51
- async postMultipart(path, formData, extraHeaders) {
52
- const headers = {
53
- ...this.getHeaders(),
54
- ...(extraHeaders ?? {}),
55
- };
56
- const response = await fetch(`${this.baseUrl}${path}`, {
57
- method: 'POST',
58
- headers,
59
- body: formData,
45
+ return parseEnvelope(response);
46
+ }
47
+ // ---- device authorization (public, no credential) ----
48
+ deviceAuthorize(suggestedName) {
49
+ const body = { clientKind: 'cli' };
50
+ if (suggestedName)
51
+ body.suggestedName = suggestedName;
52
+ return this.request('POST', '/v1/device/authorize', body, { auth: false });
53
+ }
54
+ deviceToken(deviceCode) {
55
+ return this.request('POST', '/v1/device/token', { deviceCode }, { auth: false });
56
+ }
57
+ // ---- authenticated ----
58
+ whoami() {
59
+ return this.request('GET', '/v1/developer/whoami');
60
+ }
61
+ revokeCurrentCredential() {
62
+ return this.request('POST', '/v1/developer/credential/revoke');
63
+ }
64
+ createModule(body) {
65
+ return this.request('POST', '/v1/developer/modules', body);
66
+ }
67
+ listModules(query) {
68
+ const params = new URLSearchParams({ page: String(query.page), pageSize: String(query.pageSize) });
69
+ if (query.q !== undefined)
70
+ params.set('q', query.q);
71
+ return this.request('GET', `/v1/developer/modules?${params}`);
72
+ }
73
+ updateModule(moduleRef, body) {
74
+ return this.request('PATCH', `/v1/developer/modules/${encodeURIComponent(moduleRef)}`, body);
75
+ }
76
+ /** Recoverable delete: the module can be restored until `restorableUntil`. */
77
+ deleteModule(moduleRef) {
78
+ return this.request('DELETE', `/v1/developer/modules/${encodeURIComponent(moduleRef)}`);
79
+ }
80
+ restoreModule(moduleRef) {
81
+ return this.request('POST', `/v1/developer/modules/${encodeURIComponent(moduleRef)}/restore`);
82
+ }
83
+ /** Irreversible: the version stays as a read-only record and its storage is returned. */
84
+ withdrawVersion(moduleRef, version) {
85
+ return this.request('POST', `/v1/developer/modules/${encodeURIComponent(moduleRef)}/versions/${encodeURIComponent(version)}/withdraw`);
86
+ }
87
+ /** Public catalogue route: never sends the credential, even when one is configured. */
88
+ listPublicVersions(moduleRef, query) {
89
+ const params = new URLSearchParams({
90
+ page: String(query.page),
91
+ pageSize: String(query.pageSize),
92
+ includeWithdrawn: String(query.includeWithdrawn),
60
93
  });
61
- return response.json();
62
- }
63
- async put(path, body) {
64
- const headers = {
65
- ...this.getHeaders(),
66
- 'Content-Type': 'application/json',
67
- };
68
- const response = await fetch(`${this.baseUrl}${path}`, {
69
- method: 'PUT',
70
- headers,
71
- body: body ? JSON.stringify(body) : undefined,
94
+ return this.request('GET', `/v1/catalogue/modules/${encodeURIComponent(moduleRef)}/versions?${params}`, undefined, {
95
+ auth: false,
72
96
  });
73
- return response.json();
74
97
  }
75
- async delete(path) {
76
- const response = await fetch(`${this.baseUrl}${path}`, {
77
- method: 'DELETE',
78
- headers: this.getHeaders(),
79
- });
80
- return response.json();
98
+ createVersion(moduleRef, body) {
99
+ return this.request('POST', `/v1/developer/modules/${encodeURIComponent(moduleRef)}/versions`, body);
100
+ }
101
+ requestUploadToken(moduleRef, version) {
102
+ return this.request('POST', `/v1/developer/modules/${encodeURIComponent(moduleRef)}/versions/${encodeURIComponent(version)}/upload-token`);
103
+ }
104
+ /**
105
+ * PUTs the JAR to the ticket URL. The ticket authenticates the upload, so no
106
+ * Authorization header is sent, and the URL must be on the configured API origin.
107
+ */
108
+ async uploadJar(ticket, bytes) {
109
+ let target;
110
+ try {
111
+ target = new URL(ticket.uploadUrl);
112
+ }
113
+ catch {
114
+ throw new Error('服务器返回的上传地址无效,已停止上传。');
115
+ }
116
+ if (target.origin !== new URL(this.apiUrl).origin) {
117
+ throw new Error(`上传地址 ${target.origin} 与 API 源站 ${new URL(this.apiUrl).origin} 不一致,已停止上传。`);
118
+ }
119
+ if (ticket.method !== 'PUT')
120
+ throw new Error(`上传票据的方法不是 PUT(${ticket.method}),已停止上传。`);
121
+ const headers = {};
122
+ for (const [name, value] of Object.entries(ticket.headers ?? {})) {
123
+ if (name.toLowerCase() !== 'authorization')
124
+ headers[name] = value;
125
+ }
126
+ headers['Content-Type'] = 'application/java-archive';
127
+ const body = bytes;
128
+ const response = await fetch(target, { method: 'PUT', headers, body, redirect: 'error' });
129
+ return parseEnvelope(response);
130
+ }
131
+ }
132
+ async function parseEnvelope(response) {
133
+ const text = await response.text();
134
+ let parsed;
135
+ try {
136
+ parsed = text ? JSON.parse(text) : undefined;
137
+ }
138
+ catch {
139
+ parsed = undefined;
140
+ }
141
+ if (response.ok) {
142
+ if (parsed && typeof parsed === 'object' && 'data' in parsed)
143
+ return parsed.data;
144
+ throw new ApiError(response.status, 'unexpected_response', `服务器返回了无法识别的响应(HTTP ${response.status})。`);
145
+ }
146
+ const error = parsed?.error;
147
+ if (error && typeof error.code === 'string') {
148
+ const details = { ...(error.details ?? {}) };
149
+ const retryAfter = Number(response.headers.get('retry-after'));
150
+ if (details.retryAfterSeconds === undefined && Number.isFinite(retryAfter) && retryAfter > 0) {
151
+ details.retryAfterSeconds = retryAfter;
152
+ }
153
+ return Promise.reject(new ApiError(response.status, error.code, String(error.message ?? error.code), error.requestId, details));
81
154
  }
155
+ throw new ApiError(response.status, 'unexpected_response', `服务器返回了无法识别的响应(HTTP ${response.status})。`);
82
156
  }
83
157
  //# sourceMappingURL=api-client.js.map
@@ -0,0 +1,8 @@
1
+ export type ManageOperation = 'list' | 'versions' | 'create' | 'update' | 'delete' | 'restore' | 'withdraw';
2
+ export interface ErrorContext {
3
+ op: ManageOperation;
4
+ identifier?: string;
5
+ version?: string;
6
+ apiUrl: string;
7
+ }
8
+ export declare function describeCommandError(err: unknown, ctx: ErrorContext): string;
@@ -0,0 +1,54 @@
1
+ // Messages for the errors the modules / versions commands can receive
2
+ // (tests/contract/developer-client/1.1.0/DEVELOPER-CLIENT.md, "错误码与客户端动作").
3
+ import { ApiError } from './api-client.js';
4
+ import { describeAuthError } from '../commands/whoami.js';
5
+ const MANAGE_HINT = '此操作需要 manage 凭据:用 ultikits login 登录,或在开发者中心把开发者密钥轮换为管理作用域。';
6
+ function fieldList(err) {
7
+ const fields = err.details?.fields;
8
+ if (!fields || typeof fields !== 'object')
9
+ return '';
10
+ return Object.entries(fields)
11
+ .map(([field, reason]) => `${field}: ${String(reason)}`)
12
+ .join(';');
13
+ }
14
+ function specific(err, ctx) {
15
+ const id = ctx.identifier ?? '';
16
+ switch (err.code) {
17
+ case 'key_scope_insufficient':
18
+ return MANAGE_HINT;
19
+ case 'rate_limited':
20
+ return `请求过于频繁,请 ${err.retryAfterSeconds ?? '稍'} 秒后再试(不会自动重试)。`;
21
+ case 'validation_failed':
22
+ return `参数不合格${fieldList(err) ? `:${fieldList(err)}` : '。'}`;
23
+ case 'not_found':
24
+ if (ctx.op === 'restore')
25
+ return `没有可恢复的模块 ${id}:标识符不对,或删除已超过 30 天恢复期。`;
26
+ if (ctx.op === 'withdraw')
27
+ return `模块 ${id} 或版本 ${ctx.version} 不存在,请检查标识符与版本号。`;
28
+ return `模块 ${id} 不存在,请检查标识符(用 ultikits modules list 查看你的模块)。`;
29
+ case 'version_not_published':
30
+ return `${id}@${ctx.version} 还在等待上传,不能撤回。`;
31
+ case 'module_identifier_taken':
32
+ if (ctx.op !== 'create')
33
+ return null;
34
+ return `标识符 ${id} 已被占用。若这是你 30 天内删除的模块,可用 ultikits modules restore ${id} 恢复。`;
35
+ case 'module_name_taken':
36
+ if (ctx.op !== 'create' && ctx.op !== 'update')
37
+ return null;
38
+ return '模块名称已被占用,请用 --name 换一个名称。';
39
+ default:
40
+ return null;
41
+ }
42
+ }
43
+ export function describeCommandError(err, ctx) {
44
+ if (err instanceof ApiError) {
45
+ const message = specific(err, ctx);
46
+ if (message) {
47
+ const req = err.requestId ? ` (requestId ${err.requestId})` : '';
48
+ return `${message} [${err.code}]${req}`;
49
+ }
50
+ }
51
+ // invalid_credential, role_required and anything unknown: same wording as whoami.
52
+ return describeAuthError(err, ctx.apiUrl);
53
+ }
54
+ //# sourceMappingURL=command-errors.js.map
@@ -1,6 +1,43 @@
1
- import type { Credentials } from '../types.js';
1
+ import type { CredentialKind, StoredCredential } from '../types.js';
2
+ export declare const DEFAULT_API_URL = "https://api.ultikits.com";
3
+ export declare const DEVELOPER_KEY_PATTERN: RegExp;
2
4
  export declare function getConfigDir(): string;
3
- export declare function readCredentials(configDir?: string): Credentials | null;
4
- export declare function writeCredentials(credentials: Credentials, configDir?: string): void;
5
+ export type CredentialFileState = {
6
+ status: 'none';
7
+ }
8
+ /** A pre-UltiCloud file (type oauth / access_token) or one this version cannot read. */
9
+ | {
10
+ status: 'legacy';
11
+ } | {
12
+ status: 'ok';
13
+ credential: StoredCredential;
14
+ };
15
+ export declare function readCredentials(configDir?: string): CredentialFileState;
16
+ export declare function writeCredentials(credential: StoredCredential, configDir?: string): void;
5
17
  export declare function deleteCredentials(configDir?: string): void;
6
- export declare function getApiUrl(credentials: Credentials | null): string;
18
+ /**
19
+ * Throws unless `url` is https, or plain http to a loopback host (test stubs).
20
+ * Returns the URL without a trailing slash.
21
+ */
22
+ export declare function assertAllowedApiUrl(url: string): string;
23
+ /** --api-url > ULTIKITS_API_URL > the credential file's apiUrl > production. */
24
+ export declare function resolveApiUrl(flag: string | undefined, env: NodeJS.ProcessEnv, stored?: string): string;
25
+ export declare function credentialKindOf(value: string): CredentialKind | null;
26
+ export type ResolvedAuth = {
27
+ ok: true;
28
+ credential: string;
29
+ kind: CredentialKind;
30
+ apiUrl: string;
31
+ source: 'env' | 'file';
32
+ } | {
33
+ ok: false;
34
+ message: string;
35
+ };
36
+ /** ULTIKITS_TOKEN wins over the credential file. Never sends anything. */
37
+ export declare function resolveAuth(opts: {
38
+ apiUrl?: string;
39
+ env: NodeJS.ProcessEnv;
40
+ configDir?: string;
41
+ }): ResolvedAuth;
42
+ /** Developer centre URL for the known API hosts; null for any other host. */
43
+ export declare function developerCentreUrl(apiUrl: string): string | null;
@@ -2,38 +2,117 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  const CREDENTIALS_FILE = 'credentials.json';
5
- const DEFAULT_API_URL = 'https://api.ultikits.com';
5
+ export const DEFAULT_API_URL = 'https://api.ultikits.com';
6
+ const CREDENTIAL_PATTERN = /^uc[cd]_[A-Za-z0-9_-]{43}$/;
7
+ export const DEVELOPER_KEY_PATTERN = /^ucd_[A-Za-z0-9_-]{43}$/;
6
8
  export function getConfigDir() {
7
9
  return path.join(os.homedir(), '.ultikits');
8
10
  }
9
11
  export function readCredentials(configDir) {
10
- const dir = configDir ?? getConfigDir();
11
- const filePath = path.join(dir, CREDENTIALS_FILE);
12
+ const filePath = path.join(configDir ?? getConfigDir(), CREDENTIALS_FILE);
13
+ let content;
12
14
  try {
13
- const content = fs.readFileSync(filePath, 'utf-8');
14
- return JSON.parse(content);
15
+ content = fs.readFileSync(filePath, 'utf-8');
15
16
  }
16
17
  catch {
17
- return null;
18
+ return { status: 'none' };
18
19
  }
20
+ try {
21
+ const parsed = JSON.parse(content);
22
+ if (parsed &&
23
+ parsed.version === 2 &&
24
+ (parsed.kind === 'cli' || parsed.kind === 'developer') &&
25
+ typeof parsed.credential === 'string' &&
26
+ typeof parsed.apiUrl === 'string') {
27
+ return { status: 'ok', credential: parsed };
28
+ }
29
+ }
30
+ catch {
31
+ // fall through: unreadable file is treated like an old one
32
+ }
33
+ return { status: 'legacy' };
19
34
  }
20
- export function writeCredentials(credentials, configDir) {
35
+ export function writeCredentials(credential, configDir) {
21
36
  const dir = configDir ?? getConfigDir();
22
- fs.mkdirSync(dir, { recursive: true });
37
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
23
38
  const filePath = path.join(dir, CREDENTIALS_FILE);
24
- fs.writeFileSync(filePath, JSON.stringify(credentials, null, 2), { mode: 0o600 });
39
+ fs.writeFileSync(filePath, JSON.stringify(credential, null, 2) + '\n', { mode: 0o600 });
40
+ // writeFileSync keeps the mode of an existing file, so tighten it explicitly.
41
+ fs.chmodSync(filePath, 0o600);
25
42
  }
26
43
  export function deleteCredentials(configDir) {
27
- const dir = configDir ?? getConfigDir();
28
- const filePath = path.join(dir, CREDENTIALS_FILE);
29
44
  try {
30
- fs.unlinkSync(filePath);
45
+ fs.unlinkSync(path.join(configDir ?? getConfigDir(), CREDENTIALS_FILE));
31
46
  }
32
47
  catch {
33
48
  // Already gone
34
49
  }
35
50
  }
36
- export function getApiUrl(credentials) {
37
- return credentials?.api_url ?? DEFAULT_API_URL;
51
+ /**
52
+ * Throws unless `url` is https, or plain http to a loopback host (test stubs).
53
+ * Returns the URL without a trailing slash.
54
+ */
55
+ export function assertAllowedApiUrl(url) {
56
+ let parsed;
57
+ try {
58
+ parsed = new URL(url);
59
+ }
60
+ catch {
61
+ throw new Error(`API 地址无效:${url}`);
62
+ }
63
+ const loopback = parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost';
64
+ if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback)) {
65
+ throw new Error(`API 地址必须使用 https(仅 127.0.0.1 与 localhost 可用 http):${parsed.origin}`);
66
+ }
67
+ return url.replace(/\/+$/, '');
68
+ }
69
+ /** --api-url > ULTIKITS_API_URL > the credential file's apiUrl > production. */
70
+ export function resolveApiUrl(flag, env, stored) {
71
+ return assertAllowedApiUrl(flag || env.ULTIKITS_API_URL || stored || DEFAULT_API_URL);
72
+ }
73
+ export function credentialKindOf(value) {
74
+ if (!CREDENTIAL_PATTERN.test(value))
75
+ return null;
76
+ return value.startsWith('ucc_') ? 'cli' : 'developer';
77
+ }
78
+ /** ULTIKITS_TOKEN wins over the credential file. Never sends anything. */
79
+ export function resolveAuth(opts) {
80
+ const envToken = opts.env.ULTIKITS_TOKEN?.trim();
81
+ try {
82
+ if (envToken) {
83
+ const kind = credentialKindOf(envToken);
84
+ if (!kind) {
85
+ return { ok: false, message: 'ULTIKITS_TOKEN 不是 UltiCloud 凭据(应为 ucd_ 开发者密钥或 ucc_ CLI 凭据)。' };
86
+ }
87
+ return { ok: true, credential: envToken, kind, apiUrl: resolveApiUrl(opts.apiUrl, opts.env), source: 'env' };
88
+ }
89
+ const state = readCredentials(opts.configDir);
90
+ if (state.status === 'legacy') {
91
+ return { ok: false, message: '凭据文件是旧版格式,对新接口无效,请重新 ultikits login。' };
92
+ }
93
+ if (state.status === 'none') {
94
+ return { ok: false, message: '未登录:请运行 ultikits login,或设置环境变量 ULTIKITS_TOKEN。' };
95
+ }
96
+ const stored = state.credential;
97
+ return {
98
+ ok: true,
99
+ credential: stored.credential,
100
+ kind: stored.kind,
101
+ apiUrl: resolveApiUrl(opts.apiUrl, opts.env, stored.apiUrl),
102
+ source: 'file',
103
+ };
104
+ }
105
+ catch (err) {
106
+ return { ok: false, message: err.message };
107
+ }
108
+ }
109
+ /** Developer centre URL for the known API hosts; null for any other host. */
110
+ export function developerCentreUrl(apiUrl) {
111
+ const host = new URL(apiUrl).hostname;
112
+ if (host === 'api.ultikits.com')
113
+ return 'https://panel.ultikits.com/developer';
114
+ if (host === 'api-staging.ultikits.com')
115
+ return 'https://panel-staging.ultikits.com/developer';
116
+ return null;
38
117
  }
39
118
  //# sourceMappingURL=config.js.map
@@ -0,0 +1,22 @@
1
+ export interface ConfirmOptions {
2
+ /** The string the user has to type, compared case-sensitively. */
3
+ expected: string;
4
+ /** Value of --confirm, when given. */
5
+ flagValue?: string;
6
+ /** -y / --yes was given. */
7
+ yes?: boolean;
8
+ /** stdin and stdout are a terminal. */
9
+ interactive: boolean;
10
+ /** What the operation does; shown before the question. */
11
+ warning: string;
12
+ /** Asks one question and returns the answer (tests pass a stub). */
13
+ ask?: (question: string) => Promise<string>;
14
+ }
15
+ export type ConfirmResult = {
16
+ ok: true;
17
+ } | {
18
+ ok: false;
19
+ reason: string;
20
+ };
21
+ export declare function requireConfirmation(options: ConfirmOptions): Promise<ConfirmResult>;
22
+ export declare function isInteractiveTerminal(): boolean;
@@ -0,0 +1,47 @@
1
+ // Confirmation for destructive commands (modules delete, versions withdraw).
2
+ //
3
+ // The user must type the exact target string: the module identifier, or
4
+ // <identifier>@<version> for a withdrawal. On a terminal without -y the CLI asks
5
+ // for it; otherwise --confirm <same string> is required. -y alone is never enough,
6
+ // so a script that adds -y to every command cannot delete anything by accident.
7
+ // Callers must check the result before sending any request.
8
+ import readline from 'node:readline/promises';
9
+ export async function requireConfirmation(options) {
10
+ const { expected, flagValue } = options;
11
+ if (flagValue !== undefined) {
12
+ if (flagValue === expected)
13
+ return { ok: true };
14
+ return { ok: false, reason: `--confirm 的值必须与 ${expected} 完全一致(区分大小写),未发送任何请求。` };
15
+ }
16
+ if (!options.interactive || options.yes) {
17
+ return {
18
+ ok: false,
19
+ reason: `非交互模式或使用了 -y 时,必须加 --confirm ${expected} 确认(单有 -y 不够),未发送任何请求。`,
20
+ };
21
+ }
22
+ const ask = options.ask ?? askOnTerminal;
23
+ let answer;
24
+ try {
25
+ answer = await ask(`${options.warning}\n请输入 ${expected} 确认(输入其他内容取消):`);
26
+ }
27
+ catch {
28
+ // Ctrl-C / Ctrl-D: readline rejects (AbortError on Node 24); treat it as a cancel.
29
+ return { ok: false, reason: '已取消(输入被中断),未发送任何请求。' };
30
+ }
31
+ if (answer === expected)
32
+ return { ok: true };
33
+ return { ok: false, reason: `输入与 ${expected} 不一致,已取消,未发送任何请求。` };
34
+ }
35
+ export function isInteractiveTerminal() {
36
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
37
+ }
38
+ async function askOnTerminal(question) {
39
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
40
+ try {
41
+ return await rl.question(question);
42
+ }
43
+ finally {
44
+ rl.close();
45
+ }
46
+ }
47
+ //# sourceMappingURL=confirm.js.map
@@ -3,3 +3,9 @@ export declare function error(msg: string): void;
3
3
  export declare function info(msg: string): void;
4
4
  export declare function warn(msg: string): void;
5
5
  export declare function spinner(text: string): import("ora").Ora;
6
+ /** Output sink for commands; tests pass a capturing one. */
7
+ export interface Io {
8
+ out(msg: string): void;
9
+ err(msg: string): void;
10
+ }
11
+ export declare const consoleIo: Io;
@@ -15,4 +15,8 @@ export function warn(msg) {
15
15
  export function spinner(text) {
16
16
  return ora({ text, color: 'cyan' });
17
17
  }
18
+ export const consoleIo = {
19
+ out: (msg) => console.log(msg),
20
+ err: (msg) => console.error(msg),
21
+ };
18
22
  //# sourceMappingURL=logger.js.map
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Masked form of a credential for display: known prefix + first 4 + •••• + last 4.
3
+ * Anything that does not look like a UltiCloud credential is fully masked.
4
+ */
5
+ export declare function maskCredential(value: string): string;
6
+ /** Replaces every occurrence of `secret` in `text` with its masked form. */
7
+ export declare function redact(text: string, secret: string | undefined | null): string;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Masked form of a credential for display: known prefix + first 4 + •••• + last 4.
3
+ * Anything that does not look like a UltiCloud credential is fully masked.
4
+ */
5
+ export function maskCredential(value) {
6
+ const match = /^(uc[cdu]_)(.+)$/.exec(value);
7
+ if (!match || match[2].length < 12)
8
+ return '••••';
9
+ const body = match[2];
10
+ return `${match[1]}${body.slice(0, 4)}••••${body.slice(-4)}`;
11
+ }
12
+ /** Replaces every occurrence of `secret` in `text` with its masked form. */
13
+ export function redact(text, secret) {
14
+ if (!secret)
15
+ return text;
16
+ return text.split(secret).join(maskCredential(secret));
17
+ }
18
+ //# sourceMappingURL=mask.js.map