openyida 2026.9.22 → 2026.9.23-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -74,6 +74,13 @@ openyida login
74
74
 
75
75
  OpenYida login defaults to OAuth token mode. It opens the DingTalk OAuth authorization page, receives the local loopback callback, exchanges `code` / `authCode` with the Yida server, and stores `access_token` / `refresh_token` in the user auth store when available. If the user auth store is not writable, OpenYida explicitly falls back to the current project cache and reports that narrower persistence scope.
76
76
 
77
+ In a sandbox, headless shell, or CI dashboard where the loopback callback cannot complete, use the device authorization flow. Open the displayed verification URL on any device and enter the user code. `--env-hint` optionally pins the intended environment profile at authorization time:
78
+
79
+ ```bash
80
+ openyida login --device
81
+ openyida login --device --env-hint pre
82
+ ```
83
+
77
84
  When the user names a target Yida entry URL, pass it to the login command so OpenYida can select the matching environment and auth profile. For example:
78
85
 
79
86
  ```bash
@@ -383,7 +390,7 @@ Run `openyida --help` or `openyida <command> --help` for detailed usage.
383
390
 
384
391
  | Command | Description |
385
392
  |---------|-------------|
386
- | `openyida login [target-url] [--env <name>\|--intl\|--overseas\|--global\|--yidaapps\|--alibaba] [--client-id <clientId>] [--endpoint <url>] [--no-browser]` | Login with OAuth token mode |
393
+ | `openyida login [target-url] [--env <name>\|--intl\|--overseas\|--global\|--yidaapps\|--alibaba] [--client-id <clientId>] [--endpoint <url>] [--no-browser] [--device] [--env-hint <name>]` | Login with OAuth token mode |
387
394
  | `openyida logout` | Logout / unbind current project auth |
388
395
  | `openyida auth <status\|login\|refresh\|logout\|profiles\|profile switch>` | Token login state and profile management |
389
396
  | `openyida org <list\|switch> [--json] [--corp-id <corpId>]` | Organization management (list / switch existing profiles first) |
package/bin/yida.js CHANGED
@@ -279,6 +279,7 @@ function getFirstPositionalArg(cliArgs, startIndex = 0) {
279
279
  '--login-url',
280
280
  '--profile',
281
281
  '--user-id',
282
+ '--env-hint',
282
283
  ]);
283
284
  for (let index = startIndex; index < cliArgs.length; index++) {
284
285
  const arg = cliArgs[index];
@@ -386,6 +387,7 @@ function applyLoginEnvironmentFlags(cliArgs, options = {}) {
386
387
  '--client-id',
387
388
  '--profile',
388
389
  '--user-id',
390
+ '--env-hint',
389
391
  ]);
390
392
  const targetUrlFlags = new Set([
391
393
  '--endpoint',
@@ -537,13 +539,23 @@ function printLoginHelp() {
537
539
  }
538
540
 
539
541
  function buildTokenLoginOptions(loginArgs) {
542
+ const device = loginArgs.includes('--device');
540
543
  return {
541
544
  clientId: getArgValue(loginArgs, '--client-id'),
542
545
  corpId: getArgValue(loginArgs, '--corp-id'),
543
546
  userId: getArgValue(loginArgs, '--user-id'),
544
547
  authProfile: getArgValue(loginArgs, '--profile'),
545
548
  quiet: process.env.YIDA_QUIET === '1' || loginArgs.includes('--quiet'),
546
- noBrowser: loginArgs.includes('--no-browser'),
549
+ noBrowser: device || loginArgs.includes('--no-browser'),
550
+ device,
551
+ envHint: getArgValue(loginArgs, '--env-hint'),
552
+ // Machine-readable progress events for agent runtimes (sandbox browsers,
553
+ // CI dashboards). Emits one JSON line per state transition.
554
+ onDeviceState: (state) => {
555
+ if (device) {
556
+ process.stdout.write(JSON.stringify({ type: 'device_login_state', ...state }) + '\n');
557
+ }
558
+ },
547
559
  };
548
560
  }
549
561
 
@@ -0,0 +1,278 @@
1
+ /**
2
+ * oauth-device.js - Device Authorization Grant (RFC 8628) flow.
3
+ *
4
+ * Enables headless / sandbox login: the CLI requests a device code from the
5
+ * auth service, the user completes authorization on ANY device (phone,
6
+ * another browser, CI dashboard), and the CLI polls until tokens are issued.
7
+ *
8
+ * The /device/* endpoints use a unified camelCase contract (deviceCode,
9
+ * userCode, verificationUri, grantType, accessToken, ...) matching the
10
+ * tianshu CliAuthRpc device endpoints. Legacy CLI token endpoints
11
+ * (/dingtalk/token, /refresh, /status) keep their snake_case contract and are
12
+ * not handled here.
13
+ *
14
+ * Reuses the same token normalization / profile persistence pipeline as the
15
+ * loopback flow in oauth-loopback.js.
16
+ */
17
+
18
+ const { requestJson } = require('./token-auth');
19
+
20
+ const DEVICE_CODE_PATH = '/device/code';
21
+ const DEVICE_TOKEN_PATH = '/device/token';
22
+ const DEFAULT_DEVICE_TIMEOUT_MS = 10 * 60 * 1000; // device codes are usually valid 10 minutes
23
+ const DEFAULT_POLL_INTERVAL_MS = 5 * 1000;
24
+ const SLOW_DOWN_EXTRA_DELAY_MS = 5 * 1000;
25
+
26
+ const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
27
+
28
+ function createDeviceTimeoutError() {
29
+ const timeout = new Error('device authorization timed out; device code may still be pending');
30
+ timeout.code = 'device_timeout';
31
+ return timeout;
32
+ }
33
+
34
+ function assertBeforeDeadline(deadline) {
35
+ if (Date.now() >= deadline) {
36
+ throw createDeviceTimeoutError();
37
+ }
38
+ }
39
+
40
+ function positiveNumber(value, fallback) {
41
+ const parsed = Number(value);
42
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
43
+ }
44
+
45
+ function errorDescription(payload) {
46
+ return payload.errorDescription || payload.error_description || payload.errorMsg
47
+ || payload.error_msg || payload.message || payload.raw || '';
48
+ }
49
+
50
+ /**
51
+ * Request a device code from the auth service.
52
+ *
53
+ * @param {object} options
54
+ * @param {string} options.authBaseUrl - e.g. https://yida-group.alibaba-inc.com/openapi/cli/v1/auth
55
+ * @param {string} options.clientId
56
+ * @param {string} [options.scope] - defaults handled by caller
57
+ * @param {string} [options.envHint] - environment name to pin the token audience
58
+ * @param {string} [options.timeoutMs] - overall flow timeout
59
+ * @returns {Promise<{deviceCode, userCode, verificationUri,
60
+ * verificationUriComplete, expiresIn, interval}>}
61
+ */
62
+ async function requestDeviceCode(options = {}) {
63
+ const { authBaseUrl, clientId } = options;
64
+ if (!authBaseUrl || !clientId) {
65
+ throw new Error('device code flow requires authBaseUrl and clientId');
66
+ }
67
+ const body = { clientId };
68
+ if (options.scope) { body.scope = options.scope; }
69
+ if (options.envHint) { body.envHint = options.envHint; }
70
+
71
+ const response = await requestJson(
72
+ 'POST',
73
+ `${authBaseUrl}${DEVICE_CODE_PATH}`,
74
+ body
75
+ );
76
+ const payload = unwrapPayload(response);
77
+ if (!payload.deviceCode || !payload.userCode || !payload.verificationUri) {
78
+ const message = payload.message || payload.errorDescription || payload.errorMsg
79
+ || 'auth service did not return a usable device code';
80
+ const error = new Error(message);
81
+ error.payload = payload;
82
+ throw error;
83
+ }
84
+ return payload;
85
+ }
86
+
87
+ /**
88
+ * Poll the token endpoint until the device flow completes.
89
+ *
90
+ * @param {object} options
91
+ * @param {string} options.authBaseUrl
92
+ * @param {string} options.clientId
93
+ * @param {string} options.deviceCode
94
+ * @param {number} [options.intervalMs] - from /device/code response
95
+ * @param {number} [options.timeoutMs] - overall wall clock budget
96
+ * @param {(state: object) => void} [options.onState] - progress callback for agents
97
+ * @returns {Promise<object>} token payload (accessToken, refreshToken, ...)
98
+ */
99
+ async function pollDeviceToken(options = {}) {
100
+ const { authBaseUrl, clientId, deviceCode } = options;
101
+ if (!authBaseUrl || !clientId || !deviceCode) {
102
+ throw new Error('device token polling requires authBaseUrl, clientId and deviceCode');
103
+ }
104
+ const intervalMs = Math.max(1, positiveNumber(options.intervalMs, DEFAULT_POLL_INTERVAL_MS));
105
+ const deadline = Date.now() + positiveNumber(options.timeoutMs, DEFAULT_DEVICE_TIMEOUT_MS);
106
+ let currentInterval = intervalMs;
107
+
108
+ // First poll immediately, then honor the interval.
109
+ for (;;) {
110
+ assertBeforeDeadline(deadline);
111
+ let response;
112
+ try {
113
+ response = await requestJson('POST', `${authBaseUrl}${DEVICE_TOKEN_PATH}`, {
114
+ grantType: DEVICE_GRANT_TYPE,
115
+ deviceCode: deviceCode,
116
+ clientId: clientId,
117
+ });
118
+ } catch (error) {
119
+ const payload = error.payload || {};
120
+ const errorCode = payload.error || payload.errorCode || payload.error_msg || payload.errorMsg;
121
+ if (errorCode === 'authorization_pending') {
122
+ await waitFor(deadline, currentInterval, options);
123
+ continue;
124
+ }
125
+ if (errorCode === 'slow_down') {
126
+ currentInterval += SLOW_DOWN_EXTRA_DELAY_MS;
127
+ if (options.onState) {
128
+ options.onState({ state: 'slow_down', intervalMs: currentInterval });
129
+ }
130
+ await waitFor(deadline, currentInterval, options);
131
+ continue;
132
+ }
133
+ if (errorCode === 'expired_token') {
134
+ const expired = new Error('device code expired; run login --device again');
135
+ expired.code = 'device_code_expired';
136
+ throw expired;
137
+ }
138
+ if (errorCode === 'access_denied') {
139
+ const denied = new Error('authorization was denied on the verification page');
140
+ denied.code = 'device_access_denied';
141
+ throw denied;
142
+ }
143
+ if (errorCode) {
144
+ // RFC 8628 only permits polling to continue for
145
+ // authorization_pending and slow_down. Preserve the server's
146
+ // permanent OAuth error so callers can diagnose it immediately.
147
+ error.code = String(errorCode);
148
+ const description = errorDescription(payload);
149
+ if (description) { error.message = String(description); }
150
+ throw error;
151
+ }
152
+ if (error.statusCode === 400 || error.statusCode === 429) {
153
+ // Unstructured 400/429 bodies can be produced by gateways. Retry
154
+ // those transient shapes, while the OAuth errors above remain
155
+ // terminal unless the RFC explicitly permits continued polling.
156
+ if (options.onState) {
157
+ options.onState({
158
+ state: 'poll_retry',
159
+ statusCode: error.statusCode,
160
+ body: String(errorDescription(payload)).slice(0, 200),
161
+ });
162
+ }
163
+ await waitFor(deadline, currentInterval, options);
164
+ continue;
165
+ }
166
+ throw error;
167
+ }
168
+
169
+ const payload = unwrapPayload(response);
170
+ if (!payload.accessToken && !payload.access_token) {
171
+ // 2xx without a token is unexpected; surface raw payload for diagnosis.
172
+ const invalid = new Error('auth service returned 2xx without accessToken');
173
+ invalid.payload = payload;
174
+ throw invalid;
175
+ }
176
+ return payload;
177
+ }
178
+ }
179
+
180
+ function waitFor(deadline, intervalMs, options) {
181
+ const remaining = deadline - Date.now();
182
+ if (remaining <= 0) {
183
+ throw createDeviceTimeoutError();
184
+ }
185
+ if (options && options.onState) {
186
+ options.onState({ state: 'pending', nextPollMs: Math.min(intervalMs, remaining) });
187
+ }
188
+ return new Promise((resolve) => setTimeout(resolve, Math.min(intervalMs, remaining)));
189
+ }
190
+
191
+ function unwrapPayload(response) {
192
+ if (response && typeof response === 'object' && response.content && typeof response.content === 'object') {
193
+ return response.content;
194
+ }
195
+ return response || {};
196
+ }
197
+
198
+ /**
199
+ * Run the full device code flow and return a token payload for
200
+ * normalizeTokenResponse(). Designed to be called from tokenLogin().
201
+ *
202
+ * @param {object} options - same option bag as tokenLogin plus:
203
+ * options.authBaseUrl resolved auth base (with /openapi/cli/v1/auth prefix)
204
+ * options.envHint environment name to pin audience
205
+ * options.quiet suppress human-oriented stderr
206
+ * options.onState optional machine-readable progress callback
207
+ */
208
+ async function runDeviceCodeFlow(options = {}) {
209
+ const code = await requestDeviceCode(options);
210
+
211
+ // The server may return verification URIs as absolute paths (e.g.
212
+ // /openapi/cli/v1/auth/device/verify); resolve them against the business
213
+ // base URL so users and agent hosts get clickable links.
214
+ const resolveUrl = (value) => {
215
+ if (!value || /^https?:\/\//i.test(value)) {
216
+ return value;
217
+ }
218
+ try {
219
+ const origin = new URL(options.baseUrl || options.authBaseUrl).origin;
220
+ return new URL(value, origin).toString();
221
+ } catch {
222
+ return value;
223
+ }
224
+ };
225
+ const verificationUri = resolveUrl(code.verificationUri);
226
+ const verificationUriComplete = resolveUrl(code.verificationUriComplete);
227
+
228
+ const intro = [
229
+ '',
230
+ 'Device code login:',
231
+ ` 1. Open ${verificationUri}`,
232
+ ` 2. Enter code: ${code.userCode}`,
233
+ '',
234
+ ];
235
+ if (verificationUriComplete) {
236
+ intro.push(` Or open directly: ${verificationUriComplete}`, '');
237
+ }
238
+ if (!options.quiet) {
239
+ process.stderr.write(intro.join('\n') + '\n');
240
+ }
241
+
242
+ if (options.onState) {
243
+ options.onState({
244
+ state: 'awaiting_verification',
245
+ verification_uri: verificationUri,
246
+ verification_uri_complete: verificationUriComplete,
247
+ user_code: code.userCode,
248
+ expires_in: code.expiresIn,
249
+ interval: code.interval,
250
+ });
251
+ }
252
+
253
+ const tokenPayload = await pollDeviceToken({
254
+ authBaseUrl: options.authBaseUrl,
255
+ clientId: options.clientId,
256
+ deviceCode: code.deviceCode,
257
+ intervalMs: (code.interval || 5) * 1000,
258
+ timeoutMs: Math.min(
259
+ positiveNumber(code.expiresIn, 600) * 1000,
260
+ positiveNumber(options.timeoutMs, Number.POSITIVE_INFINITY)
261
+ ),
262
+ onState: options.onState,
263
+ });
264
+
265
+ if (!options.quiet) {
266
+ process.stderr.write('Device authorization completed.\n');
267
+ }
268
+ return tokenPayload;
269
+ }
270
+
271
+ module.exports = {
272
+ runDeviceCodeFlow,
273
+ requestDeviceCode,
274
+ pollDeviceToken,
275
+ DEVICE_CODE_PATH,
276
+ DEVICE_TOKEN_PATH,
277
+ DEVICE_GRANT_TYPE,
278
+ };
@@ -302,25 +302,43 @@ async function tokenLogin(options = {}) {
302
302
  const baseUrl = resolveTokenBaseUrl(options);
303
303
  const authBaseUrl = appendPath(baseUrl, DEFAULT_AUTH_PATH_PREFIX);
304
304
  const clientId = options.clientId || process.env.OPENYIDA_DINGTALK_CLIENT_ID || DINGTALK_OAUTH_CLIENT_ID;
305
- const callback = await runDingtalkLoopback({
306
- clientId,
307
- loginOrigin: resolveDingtalkLoginOrigin(options),
308
- scope: options.scope || process.env.OPENYIDA_DINGTALK_SCOPE || 'openid corpid',
309
- prompt: options.prompt,
310
- port: options.port,
311
- quiet: options.quiet,
312
- noBrowser: options.noBrowser,
313
- timeoutMs: options.timeoutMs,
314
- });
305
+ const scope = options.scope || process.env.OPENYIDA_DINGTALK_SCOPE || 'openid corpid';
306
+
307
+ let response;
308
+ if (options.device) {
309
+ // Device Authorization Grant (RFC 8628): sandbox / headless login.
310
+ const { runDeviceCodeFlow } = require('./oauth-device');
311
+ response = await runDeviceCodeFlow({
312
+ authBaseUrl,
313
+ clientId,
314
+ scope,
315
+ envHint: options.envHint,
316
+ quiet: options.quiet,
317
+ onState: options.onDeviceState,
318
+ timeoutMs: options.timeoutMs || env.OPENYIDA_OAUTH_TIMEOUT_MS,
319
+ });
320
+ requireOkResponse(response);
321
+ } else {
322
+ const callback = await runDingtalkLoopback({
323
+ clientId,
324
+ loginOrigin: resolveDingtalkLoginOrigin(options),
325
+ scope,
326
+ prompt: options.prompt,
327
+ port: options.port,
328
+ quiet: options.quiet,
329
+ noBrowser: options.noBrowser,
330
+ timeoutMs: options.timeoutMs,
331
+ });
315
332
 
316
- const response = await requestJson('POST', appendPath(authBaseUrl, '/dingtalk/token'), {
317
- code: callback.code,
318
- authCode: callback.authCode,
319
- redirectUri: callback.redirectUri,
320
- state: callback.state,
321
- clientId,
322
- });
323
- requireOkResponse(response);
333
+ response = await requestJson('POST', appendPath(authBaseUrl, '/dingtalk/token'), {
334
+ code: callback.code,
335
+ authCode: callback.authCode,
336
+ redirectUri: callback.redirectUri,
337
+ state: callback.state,
338
+ clientId,
339
+ });
340
+ requireOkResponse(response);
341
+ }
324
342
 
325
343
  const normalized = normalizeTokenResponse(response, baseUrl, clientId);
326
344
  if (!normalized.access_token) {
@@ -1034,7 +1034,7 @@ const COMMAND_GROUPS = [
1034
1034
  id: 'auth',
1035
1035
  titleKey: 'help.group_auth',
1036
1036
  commands: [
1037
- command('login', ['login'], 'login [target-url] [--env <name>|--intl|--overseas|--global|--yidaapps|--alibaba] [--client-id <clientId>] [--endpoint <url>] [--no-browser]', 'help.cmd_login', {
1037
+ command('login', ['login'], 'login [target-url] [--env <name>|--intl|--overseas|--global|--yidaapps|--alibaba] [--client-id <clientId>] [--endpoint <url>] [--no-browser] [--device] [--env-hint <name>]', 'help.cmd_login', {
1038
1038
  requiresLogin: false,
1039
1039
  output: 'json',
1040
1040
  }),
@@ -373,8 +373,8 @@ Examples:
373
373
  save_permission_usage: 'Usage: openyida save-permission <appType> <formUuid> [--package-uuid <packageUuid>] [--data-permission <json>] [--action-permission <json>]',
374
374
  save_permission_example: "Example: openyida save-permission APP_XXX FORM-XXX --data-permission '{\"role\":\"DEFAULT\",\"dataRange\":\"SELF\"}'",
375
375
  exec_failed: '\n❌ Execution failed: {0}',
376
- login_usage: 'Usage: openyida login [entryUrl|--public|--alibaba|--intl] [--no-browser] [--check-only] [--json] [--client-id <clientId>]',
377
- login_example: 'Examples:\n openyida login # Automatically open the browser via OAuth loopback login\n openyida login --no-browser # Let the caller handle the authorization URL\n openyida login --check-only --json # Check token auth status only\n openyida login --intl # Login against the international environment\n OPENYIDA_NO_BROWSER=1 openyida login # Suppress auto-opening the browser with an environment variable\n openyida auth login # Login alias',
376
+ login_usage: 'Usage: openyida login [entryUrl|--public|--alibaba|--intl] [--no-browser] [--device] [--env-hint <name>] [--check-only] [--json] [--client-id <clientId>]',
377
+ login_example: 'Examples:\n openyida login # Automatically open the browser via OAuth loopback login\n openyida login --device # Use device authorization in a headless environment\n openyida login --device --env-hint pre # Pin device authorization to an environment profile\n openyida login --no-browser # Let the caller handle the authorization URL\n openyida login --check-only --json # Check token auth status only\n openyida login --intl # Login against the international environment\n OPENYIDA_NO_BROWSER=1 openyida login # Suppress auto-opening the browser with an environment variable\n openyida auth login # Login alias',
378
378
  login_unsupported_option: 'Removed legacy login option is no longer supported: {0}. Use token/OAuth login; env token mode reads only OPENYIDA_* tokens.',
379
379
  auth_usage: 'Usage: openyida auth <status|login|refresh|logout|profiles|profile switch>',
380
380
  auth_example: 'Examples:\n openyida auth status # View login status\n openyida auth profiles # List existing login profiles\n openyida auth profile switch <auth_profile> # Switch current project to an existing profile\n openyida auth login # Add a profile when the target does not exist\n openyida auth refresh # Refresh login session\n openyida auth logout # Unbind current project auth\n openyida auth logout --profile <auth_profile> # Delete a shared profile explicitly',
@@ -373,8 +373,8 @@ openyida - 宜搭命令行工具
373
373
  save_permission_usage: '用法: openyida save-permission <appType> <formUuid> [--package-uuid <packageUuid>] [--data-permission <json>] [--action-permission <json>]',
374
374
  save_permission_example: "示例: openyida save-permission APP_XXX FORM-XXX --data-permission '{\"role\":\"DEFAULT\",\"dataRange\":\"SELF\"}'",
375
375
  exec_failed: '\n❌ 执行失败: {0}',
376
- login_usage: '用法: openyida login [entryUrl|--public|--alibaba|--intl] [--no-browser] [--check-only] [--json] [--client-id <clientId>]',
377
- login_example: '示例:\n openyida login # 通过 OAuth loopback 自动打开浏览器登录\n openyida login --no-browser # 不自动打开浏览器,由调用方接管授权链接\n openyida login --check-only --json # 只检查 token 登录态\n openyida login --intl # 使用国际站环境登录\n OPENYIDA_NO_BROWSER=1 openyida login # 通过环境变量抑制自动打开浏览器\n openyida auth login # 登录入口别名',
376
+ login_usage: '用法: openyida login [entryUrl|--public|--alibaba|--intl] [--no-browser] [--device] [--env-hint <name>] [--check-only] [--json] [--client-id <clientId>]',
377
+ login_example: '示例:\n openyida login # 通过 OAuth loopback 自动打开浏览器登录\n openyida login --device # 在无浏览器或沙箱环境使用设备授权登录\n openyida login --device --env-hint pre # 将设备授权限定到指定环境 profile\n openyida login --no-browser # 不自动打开浏览器,由调用方接管授权链接\n openyida login --check-only --json # 只检查 token 登录态\n openyida login --intl # 使用国际站环境登录\n OPENYIDA_NO_BROWSER=1 openyida login # 通过环境变量抑制自动打开浏览器\n openyida auth login # 登录入口别名',
378
378
  login_unsupported_option: '已删除的旧登录参数不再支持: {0}。请使用 token/OAuth 登录;env token 模式下只读取 OPENYIDA_* token。',
379
379
  first_run_title: ' 🤖 OpenYida - AI 问答模式已开启! ',
380
380
  first_run_welcome: ' {0}欢迎首次使用 OpenYida!{1} 以下是快速上手指南:',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openyida",
3
- "version": "2026.9.22",
3
+ "version": "2026.9.23-beta.1",
4
4
  "description": "OpenYida CLI - 宜搭低代码 AI 开发工具(安装即用,零配置)",
5
5
  "bin": {
6
6
  "openyida": "bin/yida.js",