@ultikits/cli 1.2.0 → 2.0.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/CHANGELOG.md +41 -0
- package/README.md +179 -38
- package/dist/bin/ultikits.js +3 -1
- package/dist/src/commands/login.d.ts +13 -0
- package/dist/src/commands/login.js +118 -97
- package/dist/src/commands/logout.d.ts +2 -0
- package/dist/src/commands/logout.js +43 -6
- package/dist/src/commands/modules.d.ts +35 -0
- package/dist/src/commands/modules.js +224 -281
- package/dist/src/commands/publish.d.ts +28 -0
- package/dist/src/commands/publish.js +195 -131
- package/dist/src/commands/versions.d.ts +10 -0
- package/dist/src/commands/versions.js +101 -67
- package/dist/src/commands/whoami.d.ts +6 -0
- package/dist/src/commands/whoami.js +45 -26
- package/dist/src/lib/api-client.d.ts +56 -11
- package/dist/src/lib/api-client.js +146 -72
- package/dist/src/lib/command-errors.d.ts +8 -0
- package/dist/src/lib/command-errors.js +54 -0
- package/dist/src/lib/config.d.ts +41 -4
- package/dist/src/lib/config.js +93 -14
- package/dist/src/lib/confirm.d.ts +22 -0
- package/dist/src/lib/confirm.js +47 -0
- package/dist/src/lib/logger.d.ts +6 -0
- package/dist/src/lib/logger.js +4 -0
- package/dist/src/lib/mask.d.ts +7 -0
- package/dist/src/lib/mask.js +18 -0
- package/dist/src/lib/paging.d.ts +5 -0
- package/dist/src/lib/paging.js +16 -0
- package/dist/src/types.d.ts +143 -19
- package/package.json +1 -1
|
@@ -1,32 +1,51 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { ApiClient } from '../lib/api-client.js';
|
|
3
|
-
import
|
|
1
|
+
import { developerCentreUrl, resolveAuth } from '../lib/config.js';
|
|
2
|
+
import { ApiClient, ApiError } from '../lib/api-client.js';
|
|
3
|
+
import { maskCredential, redact } from '../lib/mask.js';
|
|
4
|
+
import { consoleIo } from '../lib/logger.js';
|
|
4
5
|
export function registerWhoamiCommand(program) {
|
|
5
6
|
program
|
|
6
7
|
.command('whoami')
|
|
7
|
-
.description('Show
|
|
8
|
-
.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
if (!client) {
|
|
12
|
-
log.error('Not logged in. Run `ultikits login` or set ULTIKITS_TOKEN.');
|
|
13
|
-
process.exit(1);
|
|
14
|
-
}
|
|
15
|
-
const spin = log.spinner('Checking...');
|
|
16
|
-
spin.start();
|
|
17
|
-
try {
|
|
18
|
-
const res = await client.get('/developer/modules');
|
|
19
|
-
if (res.code === '200') {
|
|
20
|
-
spin.succeed('Authenticated as developer');
|
|
21
|
-
log.info(`Auth type: ${credentials?.type ?? 'env var'}`);
|
|
22
|
-
}
|
|
23
|
-
else {
|
|
24
|
-
spin.fail('Authentication failed: ' + res.msg);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
catch (err) {
|
|
28
|
-
spin.fail('Failed to verify: ' + err.message);
|
|
29
|
-
}
|
|
8
|
+
.description('Show the account and credential in use')
|
|
9
|
+
.option('--api-url <url>', 'UltiCloud API URL')
|
|
10
|
+
.action(async (options) => {
|
|
11
|
+
process.exitCode = await runWhoami(options);
|
|
30
12
|
});
|
|
31
13
|
}
|
|
14
|
+
/** Message for the identity errors every authenticated command can hit. */
|
|
15
|
+
export function describeAuthError(err, apiUrl) {
|
|
16
|
+
if (err instanceof ApiError) {
|
|
17
|
+
if (err.code === 'invalid_credential')
|
|
18
|
+
return '凭据无效或已过期,请重新 ultikits login。';
|
|
19
|
+
if (err.code === 'role_required') {
|
|
20
|
+
const centre = developerCentreUrl(apiUrl);
|
|
21
|
+
return `该账号还不是开发者,请先去开发者中心申请${centre ? `:${centre}` : '。'}`;
|
|
22
|
+
}
|
|
23
|
+
if (err.code === 'rate_limited')
|
|
24
|
+
return `请求过于频繁,请 ${err.retryAfterSeconds ?? '稍'} 秒后再试。`;
|
|
25
|
+
return `请求失败:${err.message} [${err.code}]${err.requestId ? ` (requestId ${err.requestId})` : ''}`;
|
|
26
|
+
}
|
|
27
|
+
return `请求失败:${err?.message ?? String(err)}`;
|
|
28
|
+
}
|
|
29
|
+
export async function runWhoami(options, deps = {}) {
|
|
30
|
+
const io = deps.io ?? consoleIo;
|
|
31
|
+
const auth = resolveAuth({ apiUrl: options.apiUrl, env: deps.env ?? process.env, configDir: deps.configDir });
|
|
32
|
+
if (!auth.ok) {
|
|
33
|
+
io.err(auth.message);
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
36
|
+
const client = new ApiClient({ apiUrl: auth.apiUrl, credential: auth.credential });
|
|
37
|
+
try {
|
|
38
|
+
const me = await client.whoami();
|
|
39
|
+
io.out(`用户 / User: ${me.username}`);
|
|
40
|
+
io.out(`开发者 / Developer: ${me.developerName}`);
|
|
41
|
+
const scope = me.credential?.scope ?? '未知 / unknown';
|
|
42
|
+
io.out(`凭据 / Credential: ${maskCredential(auth.credential)} (${auth.kind}, scope ${scope}, from ${auth.source === 'env' ? 'ULTIKITS_TOKEN' : 'credentials.json'})`);
|
|
43
|
+
io.out(`API: ${auth.apiUrl}`);
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
io.err(redact(describeAuthError(err, auth.apiUrl), auth.credential));
|
|
48
|
+
return 1;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
32
51
|
//# sourceMappingURL=whoami.js.map
|
|
@@ -1,13 +1,58 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
|
|
4
|
-
private
|
|
5
|
-
constructor(
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
constructor(
|
|
5
|
-
this.
|
|
6
|
-
this.
|
|
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
|
-
|
|
23
|
-
const headers = {};
|
|
24
|
-
if (
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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)
|
|
42
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
43
|
+
redirect: 'error',
|
|
48
44
|
});
|
|
49
|
-
return response
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
});
|
|
80
|
-
|
|
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
|
package/dist/src/lib/config.d.ts
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
|
-
import type {
|
|
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
|
|
4
|
-
|
|
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
|
-
|
|
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;
|
package/dist/src/lib/config.js
CHANGED
|
@@ -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
|
|
11
|
-
|
|
12
|
+
const filePath = path.join(configDir ?? getConfigDir(), CREDENTIALS_FILE);
|
|
13
|
+
let content;
|
|
12
14
|
try {
|
|
13
|
-
|
|
14
|
-
return JSON.parse(content);
|
|
15
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
15
16
|
}
|
|
16
17
|
catch {
|
|
17
|
-
return
|
|
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(
|
|
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(
|
|
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(
|
|
45
|
+
fs.unlinkSync(path.join(configDir ?? getConfigDir(), CREDENTIALS_FILE));
|
|
31
46
|
}
|
|
32
47
|
catch {
|
|
33
48
|
// Already gone
|
|
34
49
|
}
|
|
35
50
|
}
|
|
36
|
-
|
|
37
|
-
|
|
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;
|