@nocobase/cli 2.1.0-alpha.19 → 2.1.0-alpha.20

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.
@@ -6,27 +6,26 @@
6
6
  * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
- import { Args, Command, Flags } from '@oclif/core';
9
+ import { Args, Command } from '@oclif/core';
10
10
  export default class PmDisable extends Command {
11
11
  static args = {
12
- file: Args.string({ description: 'file to read' }),
12
+ packages: Args.string({
13
+ required: true,
14
+ multiple: true,
15
+ description: 'Plugin package name(s) to disable (e.g. `@nocobase/plugin-sample`). Pass one or more names as separate arguments.',
16
+ }),
13
17
  };
14
- static description = 'describe the command here';
18
+ static description = 'Disable one or more plugins';
15
19
  static examples = [
16
- '<%= config.bin %> <%= command.id %>',
20
+ '<%= config.bin %> <%= command.id %> @nocobase/plugin-sample',
21
+ '<%= config.bin %> <%= command.id %> @nocobase/plugin-a @nocobase/plugin-b',
17
22
  ];
18
- static flags = {
19
- // flag with no value (-f, --force)
20
- force: Flags.boolean({ char: 'f' }),
21
- // flag with a value (-n, --name=VALUE)
22
- name: Flags.string({ char: 'n', description: 'name to print' }),
23
- };
24
23
  async run() {
25
- const { args, flags } = await this.parse(PmDisable);
26
- const name = flags.name ?? 'world';
27
- this.log(`hello ${name} from packages/core/cli/src/commands/pm/disable.ts`);
28
- if (args.file && flags.force) {
29
- this.log(`you input --force and --file: ${args.file}`);
24
+ const { args } = await this.parse(PmDisable);
25
+ const packages = args.packages;
26
+ if (!Array.isArray(packages) || packages.length === 0) {
27
+ this.error('Pass at least one plugin package name.');
30
28
  }
29
+ await this.config.runCommand('api:pm:disable', ['--await-response', '--filter-by-tk', packages.join(',')]);
31
30
  }
32
31
  }
@@ -6,27 +6,26 @@
6
6
  * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
- import { Args, Command, Flags } from '@oclif/core';
9
+ import { Args, Command } from '@oclif/core';
10
10
  export default class PmEnable extends Command {
11
11
  static args = {
12
- file: Args.string({ description: 'file to read' }),
12
+ packages: Args.string({
13
+ required: true,
14
+ multiple: true,
15
+ description: 'Plugin package name(s) to enable (e.g. `@nocobase/plugin-sample`). Pass one or more names as separate arguments.',
16
+ }),
13
17
  };
14
- static description = 'describe the command here';
18
+ static description = 'Enable one or more plugins';
15
19
  static examples = [
16
- '<%= config.bin %> <%= command.id %>',
20
+ '<%= config.bin %> <%= command.id %> @nocobase/plugin-sample',
21
+ '<%= config.bin %> <%= command.id %> @nocobase/plugin-a @nocobase/plugin-b',
17
22
  ];
18
- static flags = {
19
- // flag with no value (-f, --force)
20
- force: Flags.boolean({ char: 'f' }),
21
- // flag with a value (-n, --name=VALUE)
22
- name: Flags.string({ char: 'n', description: 'name to print' }),
23
- };
24
23
  async run() {
25
- const { args, flags } = await this.parse(PmEnable);
26
- const name = flags.name ?? 'world';
27
- this.log(`hello ${name} from packages/core/cli/src/commands/pm/enable.ts`);
28
- if (args.file && flags.force) {
29
- this.log(`you input --force and --file: ${args.file}`);
24
+ const { args } = await this.parse(PmEnable);
25
+ const packages = args.packages;
26
+ if (!Array.isArray(packages) || packages.length === 0) {
27
+ this.error('Pass at least one plugin package name.');
30
28
  }
29
+ await this.config.runCommand('api:pm:enable', ['--await-response', '--filter-by-tk', packages.join(',')]);
31
30
  }
32
31
  }
@@ -6,27 +6,16 @@
6
6
  * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
- import { Args, Command, Flags } from '@oclif/core';
9
+ import { Command } from '@oclif/core';
10
10
  export default class PmList extends Command {
11
- static args = {
12
- file: Args.string({ description: 'file to read' }),
13
- };
14
- static description = 'describe the command here';
11
+ static args = {};
12
+ static summary = 'List all plugins';
15
13
  static examples = [
16
14
  '<%= config.bin %> <%= command.id %>',
17
15
  ];
18
- static flags = {
19
- // flag with no value (-f, --force)
20
- force: Flags.boolean({ char: 'f' }),
21
- // flag with a value (-n, --name=VALUE)
22
- name: Flags.string({ char: 'n', description: 'name to print' }),
23
- };
16
+ static flags = {};
24
17
  async run() {
25
18
  const { args, flags } = await this.parse(PmList);
26
- const name = flags.name ?? 'world';
27
- this.log(`hello ${name} from packages/core/cli/src/commands/pm/list.ts`);
28
- if (args.file && flags.force) {
29
- this.log(`you input --force and --file: ${args.file}`);
30
- }
19
+ await this.config.runCommand('api:pm:list', ['--mode=summary']);
31
20
  }
32
21
  }
@@ -28,7 +28,7 @@ export default class ScaffoldMigration extends Command {
28
28
  npmArgs.push('--on', flags.on);
29
29
  }
30
30
  try {
31
- await runNocoBaseCommand(npmArgs, process.cwd(), { env: { LOGGER_SILENT: 'true' } });
31
+ await runNocoBaseCommand(npmArgs, { env: { LOGGER_SILENT: 'true' } });
32
32
  }
33
33
  catch (error) {
34
34
  const message = error instanceof Error ? error.message : String(error);
@@ -27,7 +27,7 @@ export default class ScaffoldPlugin extends Command {
27
27
  npmArgs.push('--force-recreate');
28
28
  }
29
29
  try {
30
- await runNocoBaseCommand(npmArgs, process.cwd(), { env: { LOGGER_SILENT: 'true' } });
30
+ await runNocoBaseCommand(npmArgs, { env: { LOGGER_SILENT: 'true' } });
31
31
  }
32
32
  catch (error) {
33
33
  const message = error instanceof Error ? error.message : String(error);
@@ -45,7 +45,7 @@ export default class Start extends Command {
45
45
  npmArgs.push('--launch-mode', flags['launch-mode']);
46
46
  }
47
47
  try {
48
- await runNocoBaseCommand(npmArgs, process.cwd());
48
+ await runNocoBaseCommand(npmArgs);
49
49
  }
50
50
  catch (error) {
51
51
  const message = error instanceof Error ? error.message : String(error);
@@ -25,7 +25,7 @@ export default class Upgrade extends Command {
25
25
  npmArgs.push('--skip-code-update');
26
26
  }
27
27
  try {
28
- await runNocoBaseCommand(npmArgs, process.cwd());
28
+ await runNocoBaseCommand(npmArgs);
29
29
  }
30
30
  catch (error) {
31
31
  const message = error instanceof Error ? error.message : String(error);
@@ -14,12 +14,13 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
14
14
  }
15
15
  return path;
16
16
  };
17
- import { Command } from '@oclif/core';
17
+ import { Command, loadHelpClass } from '@oclif/core';
18
18
  import { dirname, join, relative } from 'node:path';
19
19
  import { fileURLToPath, pathToFileURL } from 'node:url';
20
20
  import { collectCommandModulePaths, commandRelativePathToRegistryKey, } from "../lib/command-discovery.js";
21
21
  import { getCurrentEnvName, getEnv } from "../lib/auth-store.js";
22
22
  import { createGeneratedFlags, GeneratedApiCommand } from "../lib/generated-command.js";
23
+ import { toKebabCase } from "../lib/naming.js";
23
24
  import { loadRuntimeSync } from "../lib/runtime-store.js";
24
25
  const registryFilePath = fileURLToPath(import.meta.url);
25
26
  const commandsRoot = join(dirname(registryFilePath), '../commands');
@@ -58,15 +59,60 @@ function createRuntimeCommand(operation) {
58
59
  static operation = operation;
59
60
  };
60
61
  }
61
- function createRuntimeIndexCommand(commandId, operation) {
62
+ function createRuntimeIndexCommand(commandId, metadata) {
63
+ const summary = metadata.summary || `Work with ${commandId}`;
64
+ const description = metadata.description && metadata.description !== summary ? metadata.description : undefined;
62
65
  return class RuntimeIndexCommand extends Command {
63
- static summary = operation.resourceDescription || operation.resourceDisplayName || `Work with ${commandId}`;
64
- static description = operation.resourceDescription;
66
+ static summary = summary;
67
+ static description = description;
65
68
  async run() {
66
- this.log(`Use \`nb ${commandId} --help\` to view available subcommands.`);
69
+ await this.parse(RuntimeIndexCommand);
70
+ const Help = await loadHelpClass(this.config);
71
+ await new Help(this.config, this.config.pjson.oclif.helpOptions ?? this.config.pjson.helpOptions).showHelp([
72
+ this.id ?? commandId.replaceAll(' ', ':'),
73
+ ...this.argv,
74
+ ]);
67
75
  }
68
76
  };
69
77
  }
78
+ function getRuntimeTopicEntries(operation) {
79
+ const commandSegments = operation.commandId.split(' ');
80
+ const topLevelCommandId = commandSegments[0];
81
+ const modulePrefix = toKebabCase(operation.moduleDisplayName || operation.moduleName || '');
82
+ const isTopLevelResource = Boolean(topLevelCommandId && modulePrefix && topLevelCommandId !== modulePrefix);
83
+ const entries = [];
84
+ if (!topLevelCommandId) {
85
+ return entries;
86
+ }
87
+ if (isTopLevelResource) {
88
+ entries.push([
89
+ topLevelCommandId,
90
+ {
91
+ summary: operation.resourceDescription || operation.resourceDisplayName,
92
+ description: operation.resourceDescription,
93
+ },
94
+ ]);
95
+ return entries;
96
+ }
97
+ entries.push([
98
+ topLevelCommandId,
99
+ {
100
+ summary: operation.moduleDescription || operation.moduleDisplayName || operation.moduleName,
101
+ description: operation.moduleDescription,
102
+ },
103
+ ]);
104
+ const resourceCommandId = commandSegments.slice(0, 2).join(' ');
105
+ if (commandSegments[1]) {
106
+ entries.push([
107
+ resourceCommandId,
108
+ {
109
+ summary: operation.resourceDescription || operation.resourceDisplayName,
110
+ description: operation.resourceDescription,
111
+ },
112
+ ]);
113
+ }
114
+ return entries;
115
+ }
70
116
  const registry = {
71
117
  ...(await loadCommandsFromDirectory()),
72
118
  };
@@ -77,11 +123,11 @@ for (const operation of runtime?.commands ?? []) {
77
123
  const commandSegments = operation.commandId.split(' ');
78
124
  const commandKey = commandSegments.join(':');
79
125
  registry[`api:${commandKey}`] = createRuntimeCommand(operation);
80
- // const topLevelCommandId = commandSegments[0];
81
- // const modulePrefix = toKebabCase(operation.moduleDisplayName || operation.moduleName || '');
82
- // const isTopLevelResource = Boolean(topLevelCommandId && modulePrefix && topLevelCommandId !== modulePrefix);
83
- // if (isTopLevelResource && !registry[`api:${topLevelCommandId}`]) {
84
- // registry[`api:${topLevelCommandId}`] = createRuntimeIndexCommand(`api ${topLevelCommandId}`, operation);
85
- // }
126
+ for (const [topicCommandId, metadata] of getRuntimeTopicEntries(operation)) {
127
+ const topicKey = `api:${topicCommandId.split(' ').join(':')}`;
128
+ if (!registry[topicKey]) {
129
+ registry[topicKey] = createRuntimeIndexCommand(`api ${topicCommandId}`, metadata);
130
+ }
131
+ }
86
132
  }
87
133
  export default registry;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+ import { Help } from '@oclif/core';
10
+ export function isTopicIndexCommand(commandId, topics) {
11
+ if (!commandId) {
12
+ return false;
13
+ }
14
+ return topics.some((topic) => topic.name.startsWith(`${commandId}:`));
15
+ }
16
+ export default class RuntimeHelp extends Help {
17
+ get sortedCommands() {
18
+ return super.sortedCommands.filter((command) => !isTopicIndexCommand(command.id, this.config.topics));
19
+ }
20
+ }
@@ -1,5 +1,13 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
1
9
  import { promises as fs } from 'node:fs';
2
- import path from 'node:path';
10
+ import path, { isAbsolute } from 'node:path';
3
11
  import { resolveCliHomeDir } from './cli-home.js';
4
12
  const DEFAULT_CONFIG = {
5
13
  currentEnv: 'default',
@@ -45,10 +53,45 @@ export async function setCurrentEnv(envName, options = {}) {
45
53
  config.currentEnv = envName;
46
54
  await saveAuthConfig(config, options);
47
55
  }
56
+ export class Env {
57
+ config;
58
+ constructor(config = {}) {
59
+ this.config = config;
60
+ }
61
+ get name() {
62
+ return this.config.name;
63
+ }
64
+ get baseUrl() {
65
+ return this.config.baseUrl;
66
+ }
67
+ get auth() {
68
+ return this.config.auth;
69
+ }
70
+ get runtime() {
71
+ return this.config.runtime;
72
+ }
73
+ get appRootPath() {
74
+ const appRootPath = this.config.appRootPath;
75
+ if (!appRootPath) {
76
+ return process.cwd();
77
+ }
78
+ if (isAbsolute(appRootPath)) {
79
+ return appRootPath;
80
+ }
81
+ return path.resolve(process.cwd(), appRootPath);
82
+ }
83
+ get storagePath() {
84
+ const storagePath = this.config.storagePath;
85
+ if (isAbsolute(storagePath)) {
86
+ return storagePath;
87
+ }
88
+ return path.resolve(process.cwd(), storagePath);
89
+ }
90
+ }
48
91
  export async function getEnv(envName, options = {}) {
49
92
  const config = await loadAuthConfig(options);
50
93
  const resolved = envName || config.currentEnv || 'default';
51
- return config.envs[resolved];
94
+ return new Env({ ...config.envs[resolved], name: resolved });
52
95
  }
53
96
  function areAuthConfigsEquivalent(left, right) {
54
97
  if (!left && !right) {
@@ -78,8 +121,9 @@ async function writeEnv(envName, updater, options = {}) {
78
121
  config.currentEnv = envName;
79
122
  await saveAuthConfig(config, options);
80
123
  }
81
- export async function upsertEnv(envName, baseUrl, accessToken, options = {}) {
124
+ export async function upsertEnv(envName, config, options = {}) {
82
125
  await writeEnv(envName, (previous) => {
126
+ const { baseUrl, accessToken, ...rest } = config;
83
127
  const baseUrlChanged = previous?.baseUrl !== baseUrl;
84
128
  const nextAuth = accessToken
85
129
  ? {
@@ -94,6 +138,7 @@ export async function upsertEnv(envName, baseUrl, accessToken, options = {}) {
94
138
  ...previous,
95
139
  baseUrl,
96
140
  auth: nextAuth,
141
+ ...rest,
97
142
  runtime: baseUrlChanged || authChanged ? undefined : previous?.runtime,
98
143
  };
99
144
  }, options);
@@ -53,13 +53,17 @@ function hasBooleanFlag(argv, name) {
53
53
  return false;
54
54
  }
55
55
  function getCommandToken(argv) {
56
+ const tokens = [];
56
57
  for (const token of argv) {
57
58
  if (!token || token.startsWith('-')) {
58
59
  continue;
59
60
  }
60
- return token;
61
+ tokens.push(token);
61
62
  }
62
- return undefined;
63
+ if (tokens[0] === 'api') {
64
+ return tokens[1] ?? tokens[0];
65
+ }
66
+ return tokens[0];
63
67
  }
64
68
  function hasHelpFlag(argv) {
65
69
  return argv.includes('--help') || argv.includes('-h');
@@ -68,8 +72,9 @@ function hasVersionFlag(argv) {
68
72
  return argv.includes('--version') || argv.includes('-v');
69
73
  }
70
74
  function isBuiltinCommand(argv) {
71
- const commandToken = getCommandToken(argv);
72
- return commandToken === 'env' || commandToken === 'resource';
75
+ const commandTokens = argv.filter((token) => token && !token.startsWith('-'));
76
+ const [topic, subtopic] = commandTokens;
77
+ return topic === 'env' || topic === 'resource' || (topic === 'api' && subtopic === 'resource');
73
78
  }
74
79
  export function shouldSkipRuntimeBootstrap(argv) {
75
80
  return hasVersionFlag(argv) || isBuiltinCommand(argv);
@@ -246,7 +251,7 @@ export function formatSwaggerSchemaError(response, context) {
246
251
  `Authentication failed while loading the command runtime from \`swagger:get\`${envLabel}.`,
247
252
  `Base URL: ${context.baseUrl}`,
248
253
  details,
249
- 'Update the API key with `nb env add --name <name> --base-url <url> --token <api-key>`, log in with `nb env auth -e <name>`, or rerun the command with `--token <api-key>`.',
254
+ 'Update the API key with `nb env add <name> --base-url <url> --auth-type token --token <api-key>`, log in with `nb env auth <name>`, or rerun the command with `--token <api-key>`.',
250
255
  commandHint,
251
256
  ].join('\n');
252
257
  }
@@ -257,7 +262,7 @@ export function formatSwaggerSchemaError(response, context) {
257
262
  `Base URL: ${context.baseUrl}`,
258
263
  `Network error: ${rawMessage}`,
259
264
  'Check that the NocoBase app is running, the base URL is correct, and the server is reachable from this machine.',
260
- 'If you recently changed the server address, update it with `nb env add --name <name> --base-url <url>` and retry `nb env update`.',
265
+ 'If you recently changed the server address, update it with `nb env add <name> --base-url <url>` and retry `nb env update`.',
261
266
  'Use `nb env list` to inspect the current env configuration.',
262
267
  ].join('\n');
263
268
  }
@@ -267,7 +272,7 @@ export function formatMissingRuntimeEnvError(commandToken) {
267
272
  if (!commandToken) {
268
273
  return [
269
274
  'No env is configured for runtime commands.',
270
- 'Run `nb env add --name <name> --base-url <url>` first.',
275
+ 'Run `nb env add <name> --base-url <url>` first.',
271
276
  'If you configure multiple environments later, switch with `nb env use <name>`.',
272
277
  ].join('\n');
273
278
  }
@@ -275,7 +280,7 @@ export function formatMissingRuntimeEnvError(commandToken) {
275
280
  `Unable to resolve runtime command \`${commandToken}\`.`,
276
281
  'No env is configured, so the CLI cannot load runtime commands from `swagger:get`.',
277
282
  'If this is a built-in command or a typo, run `nb --help` to inspect available commands.',
278
- 'If this should be an application runtime command, run `nb env add --name <name> --base-url <url>` and then `nb env update`.',
283
+ 'If this should be an application runtime command, run `nb env add <name> --base-url <url>` and then `nb env update`.',
279
284
  ].join('\n');
280
285
  }
281
286
  export async function ensureRuntimeFromArgv(argv, options) {
@@ -350,7 +355,7 @@ export async function updateEnvRuntime(options) {
350
355
  if (!baseUrl) {
351
356
  throw new Error([
352
357
  `Env "${envName}" is missing a base URL.`,
353
- 'Update it with `nb env add --name <name> --base-url <url>` first.',
358
+ 'Update it with `nb env add <name> --base-url <url>` first.',
354
359
  ].join('\n'));
355
360
  }
356
361
  updateTask('Loading command runtime...');
@@ -27,13 +27,13 @@ export async function collectCommandModulePaths(commandsRoot, extension) {
27
27
  }
28
28
  /**
29
29
  * Map a path relative to `commands/` with `.js` / `.ts` to an oclif explicit-registry key.
30
- * `resource/foo.js` → `api:resource:foo` (topic `api resource …`); other paths use `:` between segments.
30
+ * `api/resource/foo.js` → `api:resource:foo`; trailing `index` maps to the parent command.
31
31
  */
32
32
  export function commandRelativePathToRegistryKey(relativePath) {
33
33
  const normalized = relativePath.replace(/\\/g, '/').replace(/\.(js|ts)$/i, '');
34
34
  const segments = normalized.split('/').filter(Boolean);
35
- // if (segments[0] === 'resource' && segments.length >= 2) {
36
- // return ['api', 'resource', ...segments.slice(1)].join(':');
37
- // }
35
+ if (segments.at(-1) === 'index') {
36
+ segments.pop();
37
+ }
38
38
  return segments.join(':');
39
39
  }
@@ -10,6 +10,9 @@ import crypto from 'node:crypto';
10
10
  import { createServer } from 'node:http';
11
11
  import { spawn } from 'node:child_process';
12
12
  import { URL } from 'node:url';
13
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
13
16
  import { getCurrentEnvName, getEnv, setEnvOauthSession, } from './auth-store.js';
14
17
  import { printInfo, printVerbose, printWarning, printWarningBlock, updateTask } from './ui.js';
15
18
  const ACCESS_TOKEN_REFRESH_WINDOW_MS = 60_000;
@@ -79,8 +82,8 @@ function formatOauthFetchFailure(prefix, options) {
79
82
  `Network error: ${options.rawMessage || 'fetch failed'}`,
80
83
  'Check that the NocoBase app is running, the base URL is correct, and the server is reachable from this machine.',
81
84
  options.envName
82
- ? `If the saved login is stale, run \`nb env auth -e ${options.envName}\` again after connectivity is restored.`
83
- : 'If the saved login is stale, run `nb env auth -e <name>` again after connectivity is restored.',
85
+ ? `If the saved login is stale, run \`nb env auth ${options.envName}\` again after connectivity is restored.`
86
+ : 'If the saved login is stale, run `nb env auth <name>` again after connectivity is restored.',
84
87
  'Use `nb env list` to inspect the current env configuration.',
85
88
  ]
86
89
  .filter(Boolean)
@@ -159,12 +162,61 @@ function buildPkcePair() {
159
162
  codeChallenge,
160
163
  };
161
164
  }
162
- function maybeOpenBrowser(url) {
165
+ function escapeHtmlAttribute(value) {
166
+ return value
167
+ .replace(/&/g, '&amp;')
168
+ .replace(/"/g, '&quot;')
169
+ .replace(/</g, '&lt;')
170
+ .replace(/>/g, '&gt;');
171
+ }
172
+ function escapeScriptString(value) {
173
+ return JSON.stringify(value).replace(/</g, '\\u003c');
174
+ }
175
+ export function buildOauthRedirectHtml(url) {
176
+ const escapedUrl = escapeHtmlAttribute(url);
177
+ return `<!doctype html>
178
+ <html>
179
+ <head>
180
+ <meta charset="utf-8">
181
+ <meta http-equiv="refresh" content="0; url=${escapedUrl}">
182
+ <title>NocoBase OAuth Login</title>
183
+ </head>
184
+ <body>
185
+ <script>window.location.replace(${escapeScriptString(url)});</script>
186
+ <p>Redirecting to the OAuth login page. If nothing happens, <a href="${escapedUrl}">continue manually</a>.</p>
187
+ </body>
188
+ </html>
189
+ `;
190
+ }
191
+ async function createWindowsBrowserRedirectFile(url) {
192
+ const directory = await mkdtemp(path.join(os.tmpdir(), 'nocobase-cli-oauth-'));
193
+ const filePath = path.join(directory, 'authorize.html');
194
+ await writeFile(filePath, buildOauthRedirectHtml(url), 'utf8');
195
+ const cleanup = setTimeout(() => {
196
+ void rm(directory, { recursive: true, force: true });
197
+ }, OAUTH_LOGIN_TIMEOUT_MS);
198
+ cleanup.unref?.();
199
+ return {
200
+ target: filePath,
201
+ cleanup: async () => {
202
+ clearTimeout(cleanup);
203
+ await rm(directory, { recursive: true, force: true });
204
+ },
205
+ };
206
+ }
207
+ async function getBrowserOpenTarget(url) {
208
+ if (process.platform !== 'win32') {
209
+ return { target: url };
210
+ }
211
+ return createWindowsBrowserRedirectFile(url);
212
+ }
213
+ async function maybeOpenBrowser(url) {
214
+ const { target, cleanup } = await getBrowserOpenTarget(url);
163
215
  const candidates = process.platform === 'darwin'
164
- ? [['open', url]]
216
+ ? [['open', target]]
165
217
  : process.platform === 'win32'
166
- ? [['cmd', '/c', 'start', '', url]]
167
- : [['xdg-open', url]];
218
+ ? [['cmd', '/c', 'start', '', target]]
219
+ : [['xdg-open', target]];
168
220
  for (const [command, ...args] of candidates) {
169
221
  try {
170
222
  const child = spawn(command, args, {
@@ -172,13 +224,19 @@ function maybeOpenBrowser(url) {
172
224
  stdio: 'ignore',
173
225
  });
174
226
  child.unref();
175
- return true;
227
+ return {
228
+ opened: true,
229
+ cleanup,
230
+ };
176
231
  }
177
232
  catch (_error) {
178
233
  continue;
179
234
  }
180
235
  }
181
- return false;
236
+ return {
237
+ opened: false,
238
+ cleanup,
239
+ };
182
240
  }
183
241
  async function createLoopbackServer(state) {
184
242
  const result = await new Promise((resolve, reject) => {
@@ -208,7 +266,26 @@ async function createLoopbackServer(state) {
208
266
  return;
209
267
  }
210
268
  res.statusCode = 200;
211
- res.end('<html><body><h1>Authentication complete</h1><p>You can return to the terminal.</p></body></html>');
269
+ res.end(`<!DOCTYPE html>
270
+ <html lang="en">
271
+ <head><meta charset="utf-8" /><title>Authentication complete</title></head>
272
+ <body>
273
+ <h1>Authentication complete</h1>
274
+ <p>You can return to the terminal.</p>
275
+ <p id="manual"></p>
276
+ <script>
277
+ setTimeout(function () {
278
+ window.close();
279
+ setTimeout(function () {
280
+ var el = document.getElementById('manual');
281
+ if (document.visibilityState === 'visible' && el) {
282
+ el.textContent = 'Please close this tab manually if it is still open.';
283
+ }
284
+ }, 400);
285
+ }, 1000);
286
+ </script>
287
+ </body>
288
+ </html>`);
212
289
  resolveWaiter(code);
213
290
  }
214
291
  catch (error) {
@@ -279,7 +356,7 @@ async function exchangeAuthorizationCode(options) {
279
356
  }
280
357
  async function refreshOauthAccessToken(options) {
281
358
  if (!options.auth.refreshToken || !options.auth.clientId) {
282
- throw new Error(`OAuth session for env "${options.envName}" cannot be refreshed. Run \`nb env auth -e ${options.envName}\`.`);
359
+ throw new Error(`OAuth session for env "${options.envName}" cannot be refreshed. Run \`nb env auth ${options.envName}\`.`);
283
360
  }
284
361
  const metadata = await fetchOauthServerMetadata(options.baseUrl, { envName: options.envName });
285
362
  const resource = options.auth.resource || getOauthResource(metadata.issuer);
@@ -306,7 +383,7 @@ async function refreshOauthAccessToken(options) {
306
383
  });
307
384
  const data = await parseJsonResponse(response);
308
385
  if (!response.ok) {
309
- throw new Error(formatOauthError(`Failed to refresh OAuth session for env "${options.envName}". Run \`nb env auth -e ${options.envName}\` again`, data, response.status));
386
+ throw new Error(formatOauthError(`Failed to refresh OAuth session for env "${options.envName}". Run \`nb env auth ${options.envName}\` again`, data, response.status));
310
387
  }
311
388
  if (!data || typeof data !== 'object' || typeof data.access_token !== 'string') {
312
389
  throw new Error(`OAuth refresh response for env "${options.envName}" is missing access_token.`);
@@ -344,7 +421,7 @@ export async function resolveAccessToken(options) {
344
421
  }
345
422
  const baseUrl = options.baseUrl ?? env.baseUrl;
346
423
  if (!baseUrl) {
347
- throw new Error(`Env "${envName}" is missing a base URL. Run \`nb env add --name ${envName} --base-url <url>\`.`);
424
+ throw new Error(`Env "${envName}" is missing a base URL. Run \`nb env add ${envName} --base-url <url>\`.`);
348
425
  }
349
426
  printVerbose(`Refreshing OAuth session for env "${envName}"`);
350
427
  return refreshOauthAccessToken({
@@ -376,7 +453,7 @@ export async function authenticateEnvWithOauth(options) {
376
453
  if (!baseUrl) {
377
454
  throw new Error([
378
455
  `Env "${envName}" is missing a base URL.`,
379
- 'Run `nb env add --name <name> --base-url <url>` first.',
456
+ 'Run `nb env add <name> --base-url <url>` first.',
380
457
  ].join('\n'));
381
458
  }
382
459
  updateTask(`Loading OAuth metadata for env "${envName}"...`);
@@ -385,6 +462,7 @@ export async function authenticateEnvWithOauth(options) {
385
462
  const { codeVerifier, codeChallenge } = buildPkcePair();
386
463
  const callback = await createLoopbackServer(state);
387
464
  const resource = getOauthResource(metadata.issuer);
465
+ let cleanupBrowserOpenTarget;
388
466
  try {
389
467
  updateTask(`Registering OAuth client for env "${envName}"...`);
390
468
  const registration = await registerOauthClient(metadata, callback.redirectUri);
@@ -399,8 +477,9 @@ export async function authenticateEnvWithOauth(options) {
399
477
  authorizationUrl.searchParams.set('code_challenge_method', 'S256');
400
478
  authorizationUrl.searchParams.set('resource', resource);
401
479
  updateTask(`Waiting for OAuth login for env "${envName}"...`);
402
- const opened = maybeOpenBrowser(authorizationUrl.toString());
403
- if (!opened) {
480
+ const browser = await maybeOpenBrowser(authorizationUrl.toString());
481
+ cleanupBrowserOpenTarget = browser.cleanup;
482
+ if (!browser.opened) {
404
483
  printWarningBlock('Unable to open the browser automatically. Open this URL manually:');
405
484
  }
406
485
  else {
@@ -442,6 +521,7 @@ export async function authenticateEnvWithOauth(options) {
442
521
  }, { scope: options.scope });
443
522
  }
444
523
  finally {
524
+ await cleanupBrowserOpenTarget?.().catch(() => undefined);
445
525
  await callback.close().catch(() => undefined);
446
526
  }
447
527
  }