@factorialco/fcode-cli 3.1.0 → 3.2.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.
package/README.md CHANGED
@@ -20,7 +20,7 @@ $ npm install -g @factorialco/fcode-cli
20
20
  $ fcode COMMAND
21
21
  running command...
22
22
  $ fcode (--version)
23
- @factorialco/fcode-cli/3.1.0 linux-x64 node-v22.22.3
23
+ @factorialco/fcode-cli/3.2.0 linux-x64 node-v22.22.3
24
24
  $ fcode --help [COMMAND]
25
25
  USAGE
26
26
  $ fcode COMMAND
@@ -31,6 +31,7 @@ USAGE
31
31
  ## Commands
32
32
  <!-- commands -->
33
33
  * [`fcode add`](#fcode-add)
34
+ * [`fcode app:push`](#fcode-apppush)
34
35
  * [`fcode clone [TEAM]`](#fcode-clone-team)
35
36
  * [`fcode config:get [KEY]`](#fcode-configget-key)
36
37
  * [`fcode config:remove KEY`](#fcode-configremove-key)
@@ -116,6 +117,26 @@ EXAMPLES
116
117
  $ fcode add
117
118
  ```
118
119
 
120
+ ## `fcode app:push`
121
+
122
+ Upload the App folder's README.md and MARKETPLACE.md to the dashboard
123
+
124
+ ```
125
+ USAGE
126
+ $ fcode app:push [-f]
127
+
128
+ FLAGS
129
+ -f, --force Force command
130
+
131
+ DESCRIPTION
132
+ Upload the App folder's README.md and MARKETPLACE.md to the dashboard
133
+
134
+ EXAMPLES
135
+ $ fcode app:push
136
+
137
+ $ fcode app:push --force
138
+ ```
139
+
119
140
  ## `fcode clone [TEAM]`
120
141
 
121
142
  Clone team workspace from cloud
@@ -566,7 +587,8 @@ FLAGS
566
587
  -t, --accessToken=<value> Access Token (to be used instead of email and password)
567
588
  -u, --url=<value> [default: https://code.factorialhr.com/platform] Factorial Code Cloud endpoint (host or
568
589
  full /platform URL, ie: code.factorialhr.com)
569
- -x, --prompt Prompt for email and password (instead of opening browser)
590
+ -x, --prompt Prompt for email and password (instead of opening browser). Make it the default with
591
+ 'fcode config:set disableBrowserLogin true' or FCODE_DISABLE_BROWSER_LOGIN=true
570
592
 
571
593
  DESCRIPTION
572
594
  Login with your Factorial Code credentials (browser OAuth or email/password)
@@ -0,0 +1,13 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class AppPushCommand extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ force: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
7
+ };
8
+ init(): Promise<void>;
9
+ run(): Promise<void>;
10
+ private pushFile;
11
+ private decide;
12
+ private reportFailure;
13
+ }
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const core_1 = require("@oclif/core");
4
+ const fs = require("fs-extra");
5
+ const path = require("node:path");
6
+ const color_1 = require("@oclif/color");
7
+ const dashboard_client_1 = require("../../services/dashboard-client");
8
+ const dev_team_1 = require("../../config/dev-team");
9
+ const global_1 = require("../../config/global");
10
+ const app_metadata_files_1 = require("../../services/app-metadata-files");
11
+ const errors_1 = require("../../core/errors");
12
+ const types_1 = require("../../core/types");
13
+ const cli_1 = require("../../utils/cli");
14
+ /** Wire field names as the dashboard reports them, translated to what the file calls them. */
15
+ const FIELD_NAMES = {
16
+ '/full_description': 'description body',
17
+ '/dato_category_id': 'category_id',
18
+ '/readme': 'content',
19
+ };
20
+ const README = {
21
+ file: app_metadata_files_1.APP_README_FILENAME,
22
+ hashKey: 'readmeHash',
23
+ cloud: async ({ client, app }) => (0, app_metadata_files_1.readmeRendering)(app, await client.getAppReadme(app.id)),
24
+ upload: async ({ client, app }, local) => (0, app_metadata_files_1.readmeRendering)(app, await client.putAppReadme(app.id, (0, app_metadata_files_1.readmeForPush)(local))),
25
+ };
26
+ const MARKETPLACE = {
27
+ file: app_metadata_files_1.APP_MARKETPLACE_FILENAME,
28
+ hashKey: 'marketplaceHash',
29
+ cloud: async ({ client, app }) => (0, app_metadata_files_1.marketplaceRendering)(app, await client.getAppMarketplace(app.id)),
30
+ upload: async ({ client, app }, local) => {
31
+ const input = (0, app_metadata_files_1.marketplaceForPush)((0, app_metadata_files_1.parseMarketplaceFile)(local));
32
+ if (input === 'empty') {
33
+ return 'empty';
34
+ }
35
+ return (0, app_metadata_files_1.marketplaceRendering)(app, { metadata: await client.putAppMarketplace(app.id, input) });
36
+ },
37
+ };
38
+ class AppPushCommand extends core_1.Command {
39
+ async init() {
40
+ const { url } = (0, global_1.parseTeamOrigin)('');
41
+ const { authUrl } = await new global_1.default().getAuthInfo(url);
42
+ await this.config.runHook('auth', { argv: [url, authUrl], id: 'login' });
43
+ }
44
+ async run() {
45
+ const { flags: { force }, } = await this.parse(AppPushCommand);
46
+ const appDir = (0, dev_team_1.findAppFolder)();
47
+ if (!appDir) {
48
+ throw new dev_team_1.NotAppFolder();
49
+ }
50
+ const rootDir = (0, dev_team_1.findDevTeamRoot)(appDir);
51
+ if (!rootDir) {
52
+ throw new dev_team_1.NotDevTeamFolder();
53
+ }
54
+ const config = new dev_team_1.default(rootDir);
55
+ const metadata = config.read();
56
+ const { id } = (0, dev_team_1.readAppSettings)(appDir);
57
+ const recorded = metadata.apps[id];
58
+ if (!recorded) {
59
+ this.error(`App ${id} is not recorded in .fcode/team.json — run fcode team:pull from ${path.basename(rootDir)}`, {
60
+ code: 'ERR_APP_NOT_RECORDED',
61
+ exit: 1,
62
+ });
63
+ }
64
+ const client = new dashboard_client_1.default(metadata.cloudUrl);
65
+ const app = await client.getApp(id);
66
+ cli_1.default.log(color_1.default.cyan(`\n📤 pushing metadata of '${app.name}'...`));
67
+ const context = { client, app, appDir, recorded, force };
68
+ const outcomes = [];
69
+ try {
70
+ for (const target of [README, MARKETPLACE]) {
71
+ outcomes.push(await this.pushFile(context, target));
72
+ }
73
+ }
74
+ finally {
75
+ // A hash recorded by a file that did push is kept even when the next one fails.
76
+ config.write(metadata);
77
+ }
78
+ if (outcomes.includes(false)) {
79
+ process.exitCode = 1;
80
+ }
81
+ }
82
+ async pushFile(context, target) {
83
+ const { file } = target;
84
+ const filePath = path.join(context.appDir, file);
85
+ if (!fs.pathExistsSync(filePath)) {
86
+ cli_1.default.log(`- ${file} not present — nothing to push`);
87
+ return true;
88
+ }
89
+ const local = fs.readFileSync(filePath, 'utf8');
90
+ try {
91
+ const decision = this.decide(local, context.recorded[target.hashKey], await target.cloud(context), context.force);
92
+ if (decision !== 'push') {
93
+ return decision === 'skip';
94
+ }
95
+ const saved = await target.upload(context, local);
96
+ if (saved === 'empty') {
97
+ cli_1.default.log(`ℹ️ ${file} has no description yet — nothing to push`);
98
+ return true;
99
+ }
100
+ context.recorded[target.hashKey] = (0, app_metadata_files_1.writeEditable)(context.appDir, saved);
101
+ cli_1.default.log(color_1.default.green(`✅ ${file} pushed`));
102
+ return true;
103
+ }
104
+ catch (error) {
105
+ return this.reportFailure(file, error);
106
+ }
107
+ }
108
+ decide(local, storedHash, cloud, force) {
109
+ if (force) {
110
+ return 'push';
111
+ }
112
+ const { file } = cloud;
113
+ switch ((0, app_metadata_files_1.metadataSyncState)(local, storedHash, cloud)) {
114
+ case 'up-to-date':
115
+ cli_1.default.log(`- ${file} up to date`);
116
+ return 'skip';
117
+ case 'out-of-date':
118
+ cli_1.default.log(color_1.default.yellow(`🔻 ${file} is behind the cloud — run fcode team:pull`));
119
+ return 'skip';
120
+ case 'conflict':
121
+ cli_1.default.log(color_1.default.red(`🔥 ${file} changed both locally and in the cloud — fcode app:push --force to overwrite the cloud, or delete the file and fcode team:pull to take the cloud version`));
122
+ return 'refuse';
123
+ default:
124
+ return 'push';
125
+ }
126
+ }
127
+ reportFailure(file, error) {
128
+ if (error instanceof dashboard_client_1.DashboardApiError && error.code === 'VALIDATION_ERROR') {
129
+ const detail = error.detail.replace(/(^|; )(\/[a-z_]+)/g, (_, sep, field) => `${sep}${FIELD_NAMES[field] ?? field.slice(1)}`);
130
+ cli_1.default.log(color_1.default.red(`❌ ${file} rejected: ${detail}`));
131
+ }
132
+ else if (error instanceof dashboard_client_1.DashboardApiError && error.status === 403) {
133
+ cli_1.default.log(color_1.default.red(`❌ ${file} not pushed: you are not a member of this App`));
134
+ }
135
+ else if (error instanceof errors_1.CLIError) {
136
+ cli_1.default.log(color_1.default.red(`❌ ${error.message}`));
137
+ }
138
+ else {
139
+ cli_1.default.log(color_1.default.red(`❌ ${file} not pushed: ${error.message}`));
140
+ }
141
+ return false;
142
+ }
143
+ }
144
+ AppPushCommand.description = "Upload the App folder's README.md and MARKETPLACE.md to the dashboard";
145
+ AppPushCommand.examples = ['$ fcode app:push', '$ fcode app:push --force'];
146
+ AppPushCommand.flags = types_1.ForceFlag;
147
+ exports.default = AppPushCommand;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const core_1 = require("@oclif/core");
4
4
  const global_1 = require("../config/global");
5
+ const settings_1 = require("../config/settings");
5
6
  const url_utils_1 = require("../utils/url-utils");
6
7
  const cli_1 = require("../utils/cli");
7
8
  class Login extends core_1.Command {
@@ -57,7 +58,7 @@ Login.flags = {
57
58
  }),
58
59
  prompt: core_1.Flags.boolean({
59
60
  char: 'x',
60
- description: 'Prompt for email and password (instead of opening browser)',
61
+ description: `Prompt for email and password (instead of opening browser). Make it the default with 'fcode config:set ${settings_1.SettingKeys.DISABLE_BROWSER_LOGIN} true' or FCODE_DISABLE_BROWSER_LOGIN=true`,
61
62
  default: false,
62
63
  }),
63
64
  };
@@ -55,7 +55,7 @@ class TeamCloneCommand extends core_1.Command {
55
55
  if (!(await this.skipSkills(flags.skipSkillsSetup))) {
56
56
  await (0, skills_installer_1.installSkillsAt)(rootDir);
57
57
  }
58
- const result = await (0, dev_team_service_1.syncTeam)(rootDir, metadata, remote);
58
+ const result = await (0, dev_team_service_1.syncTeam)(rootDir, metadata, remote, client);
59
59
  config.write(metadata);
60
60
  if ((0, dev_team_service_1.reportOutcomes)(result)) {
61
61
  process.exitCode = 1;
@@ -20,8 +20,9 @@ class TeamPullCommand extends core_1.Command {
20
20
  const metadata = config.read();
21
21
  const { developmentTeam } = metadata;
22
22
  cli_1.default.log(color_1.default.cyan(`\n🏢 pulling '${developmentTeam.name}'...`));
23
- const remote = await (0, dev_team_service_1.fetchTeamRemote)(new dashboard_client_1.default(metadata.cloudUrl), developmentTeam.id);
24
- const result = await (0, dev_team_service_1.syncTeam)(rootDir, metadata, remote);
23
+ const client = new dashboard_client_1.default(metadata.cloudUrl);
24
+ const remote = await (0, dev_team_service_1.fetchTeamRemote)(client, developmentTeam.id);
25
+ const result = await (0, dev_team_service_1.syncTeam)(rootDir, metadata, remote, client);
25
26
  // Written even when a workspace failed, so the ones that did land are recorded and a rerun
26
27
  // only retries what is still missing.
27
28
  config.write(metadata);
@@ -4,4 +4,6 @@ export default class TeamStatusCommand extends Command {
4
4
  static examples: string[];
5
5
  init(): Promise<void>;
6
6
  run(): Promise<void>;
7
+ private remoteApps;
8
+ private reportMetadata;
7
9
  }
@@ -4,11 +4,21 @@ const core_1 = require("@oclif/core");
4
4
  const fs = require("fs-extra");
5
5
  const path = require("node:path");
6
6
  const color_1 = require("@oclif/color");
7
+ const dashboard_client_1 = require("../../services/dashboard-client");
7
8
  const dev_team_1 = require("../../config/dev-team");
8
9
  const global_1 = require("../../config/global");
9
10
  const status_1 = require("../status");
11
+ const app_metadata_files_1 = require("../../services/app-metadata-files");
10
12
  const workspace_cloner_1 = require("../../services/workspace-cloner");
11
13
  const cli_1 = require("../../utils/cli");
14
+ const STATUS_LINES = {
15
+ 'up-to-date': (file) => color_1.default.green(`✅ ${file} up to date`),
16
+ modified: (file) => color_1.default.yellow(`🔺 ${file} modified locally (run fcode app:push)`),
17
+ unsynced: (file) => color_1.default.yellow(`🔺 ${file} never synced (run fcode app:push, or delete it and fcode team:pull)`),
18
+ 'out-of-date': (file) => color_1.default.yellow(`🔻 ${file} out of date (run fcode team:pull)`),
19
+ conflict: (file) => color_1.default.red(`🔥 ${file} conflict (changed both locally and in the cloud)`),
20
+ missing: (file) => color_1.default.yellow(`🔻 ${file} missing (run fcode team:pull)`),
21
+ };
12
22
  class TeamStatusCommand extends core_1.Command {
13
23
  async init() {
14
24
  const { url } = (0, global_1.parseTeamOrigin)('');
@@ -19,12 +29,18 @@ class TeamStatusCommand extends core_1.Command {
19
29
  await this.parse(TeamStatusCommand);
20
30
  const rootDir = process.cwd();
21
31
  const metadata = new dev_team_1.default(rootDir).read();
32
+ const client = new dashboard_client_1.default(metadata.cloudUrl);
33
+ const remoteApps = await this.remoteApps(client, metadata.developmentTeam.id);
22
34
  const checkouts = [
23
35
  ...Object.values(metadata.globalWorkspaces).map(({ name, slug }) => ({ name, folder: (0, dev_team_1.globalWorkspaceFolder)(slug) })),
24
- ...Object.values(metadata.apps).map(({ name, folder }) => ({ name, folder: path.join(folder, dev_team_1.APP_WORKSPACE_DIR) })),
36
+ ...Object.entries(metadata.apps).map(([id, recorded]) => ({
37
+ name: recorded.name,
38
+ folder: path.join(recorded.folder, dev_team_1.APP_WORKSPACE_DIR),
39
+ app: { dir: path.join(rootDir, recorded.folder), id, recorded },
40
+ })),
25
41
  ];
26
42
  const failed = [];
27
- for (const { name, folder } of checkouts) {
43
+ for (const { name, folder, app } of checkouts) {
28
44
  cli_1.default.log(color_1.default.cyan(`\n━━ ${name} (${folder}) ━━`));
29
45
  const workspaceDir = path.join(rootDir, folder);
30
46
  if (!fs.pathExistsSync(workspaceDir)) {
@@ -32,6 +48,9 @@ class TeamStatusCommand extends core_1.Command {
32
48
  failed.push(folder);
33
49
  continue;
34
50
  }
51
+ if (app) {
52
+ await this.reportMetadata(client, app, remoteApps.get(app.id));
53
+ }
35
54
  try {
36
55
  await (0, workspace_cloner_1.runInWorkspace)(workspaceDir, () => status_1.default.run([]));
37
56
  }
@@ -45,6 +64,29 @@ class TeamStatusCommand extends core_1.Command {
45
64
  cli_1.default.log(color_1.default.red(`\n${failed.length} workspace(s) could not be checked: ${failed.join(', ')}`));
46
65
  }
47
66
  }
67
+ async remoteApps(client, teamId) {
68
+ try {
69
+ return new Map((await client.listTeamApps(teamId)).map((app) => [app.id, app]));
70
+ }
71
+ catch (error) {
72
+ cli_1.default.log(color_1.default.yellow(`⚠️ App metadata files not checked: ${error.message}`));
73
+ return new Map();
74
+ }
75
+ }
76
+ async reportMetadata(client, { dir, id, recorded }, app) {
77
+ if (!app) {
78
+ return;
79
+ }
80
+ try {
81
+ const [readme, marketplace] = await Promise.all([client.getAppReadme(id), client.getAppMarketplace(id)]);
82
+ for (const { file, state } of (0, app_metadata_files_1.metadataStatus)(dir, app, recorded, { readme, marketplace })) {
83
+ cli_1.default.log(STATUS_LINES[state](file));
84
+ }
85
+ }
86
+ catch (error) {
87
+ cli_1.default.log(color_1.default.yellow(`⚠️ README.md / MARKETPLACE.md not checked: ${error.message}`));
88
+ }
89
+ }
48
90
  }
49
91
  TeamStatusCommand.description = 'Show what differs from the cloud in every workspace of the development team folder';
50
92
  TeamStatusCommand.examples = ['$ fcode team:status'];
@@ -18,6 +18,10 @@ export interface DevTeamApp {
18
18
  folder: string;
19
19
  /** Workspace the checkout was cloned from. The directory itself is always `APP_WORKSPACE_DIR`. */
20
20
  devWorkspaceSlug: string;
21
+ /** md5 of README.md as last written by a pull or confirmed by app:push. Absent = never synced. */
22
+ readmeHash?: string;
23
+ /** Same for MARKETPLACE.md. */
24
+ marketplaceHash?: string;
21
25
  }
22
26
  export interface DevTeamGlobalWorkspace {
23
27
  name: string;
@@ -41,6 +45,9 @@ export declare function globalWorkspaceFolder(slug: string): string;
41
45
  export declare class NotDevTeamFolder extends CLIError {
42
46
  constructor();
43
47
  }
48
+ export declare class NotAppFolder extends CLIError {
49
+ constructor();
50
+ }
44
51
  export default class DevTeamConfig {
45
52
  private readonly rootDir;
46
53
  constructor(rootDir?: string);
@@ -62,3 +69,11 @@ export interface AppSettings {
62
69
  */
63
70
  export declare const APP_SETTINGS_FILENAME = "settings.json";
64
71
  export declare function writeAppSettings(appDir: string, app: AppSettings): void;
72
+ export declare function readAppSettings(appDir: string): AppSettings | undefined;
73
+ /**
74
+ * The App folder at or above `startDir`: the one holding an App `settings.json` (with an `id`) next
75
+ * to an `app/` checkout — the pair a workspace's own `settings.json` never has. Resolves from inside
76
+ * the checkout too.
77
+ */
78
+ export declare function findAppFolder(startDir?: string): string | undefined;
79
+ export declare function findDevTeamRoot(startDir?: string): string | undefined;
@@ -1,8 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.APP_SETTINGS_FILENAME = exports.NotDevTeamFolder = exports.GLOBAL_WORKSPACES_DIR = exports.APP_WORKSPACE_DIR = void 0;
3
+ exports.APP_SETTINGS_FILENAME = exports.NotAppFolder = exports.NotDevTeamFolder = exports.GLOBAL_WORKSPACES_DIR = exports.APP_WORKSPACE_DIR = void 0;
4
4
  exports.globalWorkspaceFolder = globalWorkspaceFolder;
5
5
  exports.writeAppSettings = writeAppSettings;
6
+ exports.readAppSettings = readAppSettings;
7
+ exports.findAppFolder = findAppFolder;
8
+ exports.findDevTeamRoot = findDevTeamRoot;
6
9
  const fs = require("fs-extra");
7
10
  const path = require("node:path");
8
11
  const errors_1 = require("../core/errors");
@@ -41,6 +44,16 @@ class NotDevTeamFolder extends errors_1.CLIError {
41
44
  }
42
45
  }
43
46
  exports.NotDevTeamFolder = NotDevTeamFolder;
47
+ class NotAppFolder extends errors_1.CLIError {
48
+ constructor() {
49
+ super('⚠️ Current directory is not a Factorial Code App folder (settings.json + app/).', {
50
+ code: 'ERR_NOT_APP_FOLDER',
51
+ exit: 1,
52
+ suggestions: ['cd <app-folder>', 'fcode team:clone'],
53
+ });
54
+ }
55
+ }
56
+ exports.NotAppFolder = NotAppFolder;
44
57
  class DevTeamConfig {
45
58
  constructor(rootDir = process.cwd()) {
46
59
  this.rootDir = rootDir;
@@ -74,3 +87,43 @@ exports.APP_SETTINGS_FILENAME = 'settings.json';
74
87
  function writeAppSettings(appDir, app) {
75
88
  fs.outputJsonSync(path.join(appDir, exports.APP_SETTINGS_FILENAME), app, { spaces: 2 });
76
89
  }
90
+ function readAppSettings(appDir) {
91
+ const settingsPath = path.join(appDir, exports.APP_SETTINGS_FILENAME);
92
+ if (!fs.pathExistsSync(settingsPath)) {
93
+ return undefined;
94
+ }
95
+ try {
96
+ const settings = fs.readJsonSync(settingsPath);
97
+ return typeof settings.id === 'string' ? settings : undefined;
98
+ }
99
+ catch {
100
+ return undefined;
101
+ }
102
+ }
103
+ function isAppFolder(dir) {
104
+ return readAppSettings(dir) !== undefined && fs.pathExistsSync(path.join(dir, exports.APP_WORKSPACE_DIR));
105
+ }
106
+ function findUpwards(startDir, matches) {
107
+ let dir = path.resolve(startDir);
108
+ for (;;) {
109
+ if (matches(dir)) {
110
+ return dir;
111
+ }
112
+ const parent = path.dirname(dir);
113
+ if (parent === dir) {
114
+ return undefined;
115
+ }
116
+ dir = parent;
117
+ }
118
+ }
119
+ /**
120
+ * The App folder at or above `startDir`: the one holding an App `settings.json` (with an `id`) next
121
+ * to an `app/` checkout — the pair a workspace's own `settings.json` never has. Resolves from inside
122
+ * the checkout too.
123
+ */
124
+ function findAppFolder(startDir = process.cwd()) {
125
+ return findUpwards(startDir, isAppFolder);
126
+ }
127
+ function findDevTeamRoot(startDir = process.cwd()) {
128
+ return findUpwards(startDir, (dir) => new DevTeamConfig(dir).exists());
129
+ }
@@ -4,6 +4,7 @@ export interface SettingDefinition {
4
4
  description?: string;
5
5
  }
6
6
  export declare enum SettingKeys {
7
+ DISABLE_BROWSER_LOGIN = "disableBrowserLogin",
7
8
  DISABLE_UPDATE_NOTIFIER = "disableUpdateNotifier",
8
9
  IGNORE_PROCESS_VERSIONS = "ignoreProcessVersions",
9
10
  IGNORE_WORKSPACE_NOT_EMPTY = "ignoreWorkspaceNotEmpty",
@@ -4,6 +4,7 @@ exports.SETTINGS_DEFINITIONS = exports.SettingKeys = void 0;
4
4
  exports.getSettingDefault = getSettingDefault;
5
5
  var SettingKeys;
6
6
  (function (SettingKeys) {
7
+ SettingKeys["DISABLE_BROWSER_LOGIN"] = "disableBrowserLogin";
7
8
  SettingKeys["DISABLE_UPDATE_NOTIFIER"] = "disableUpdateNotifier";
8
9
  SettingKeys["IGNORE_PROCESS_VERSIONS"] = "ignoreProcessVersions";
9
10
  SettingKeys["IGNORE_WORKSPACE_NOT_EMPTY"] = "ignoreWorkspaceNotEmpty";
@@ -24,6 +25,11 @@ exports.SETTINGS_DEFINITIONS = [
24
25
  default: 'false',
25
26
  description: 'Disable update notifier',
26
27
  },
28
+ {
29
+ key: SettingKeys.DISABLE_BROWSER_LOGIN,
30
+ default: 'false',
31
+ description: 'Disable the interactive browser OAuth login; fcode login prompts for email and password instead',
32
+ },
27
33
  {
28
34
  key: SettingKeys.SKIP_SKILLS_SETUP,
29
35
  default: 'false',
@@ -1,3 +1,22 @@
1
1
  import { Hook } from '@oclif/core';
2
+ import GlobalConfig from '../../config/global';
3
+ export type LoginMethod = 'accessToken' | 'credentials' | 'browser';
4
+ export interface LoginOptions {
5
+ email?: string;
6
+ password?: string;
7
+ clientId?: string;
8
+ clientSecret?: string;
9
+ accessToken?: string;
10
+ prompt?: boolean;
11
+ browserLoginDisabled?: boolean;
12
+ }
13
+ /**
14
+ * Picks how to log in from what the user passed. The browser OAuth flow is the default, but it is
15
+ * skipped when the user asked to be prompted (`--prompt`), when it is disabled through the
16
+ * `disableBrowserLogin` setting or the `FCODE_DISABLE_BROWSER_LOGIN` env var (headless machines,
17
+ * CI, remote shells), or when credentials that identify another method were given.
18
+ */
19
+ export declare function resolveLoginMethod(options: LoginOptions): LoginMethod;
20
+ export declare function isBrowserLoginDisabled(globalConfig?: GlobalConfig): Promise<boolean>;
2
21
  declare const auth: Hook<'init'>;
3
22
  export default auth;
@@ -1,27 +1,61 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveLoginMethod = resolveLoginMethod;
4
+ exports.isBrowserLoginDisabled = isBrowserLoginDisabled;
3
5
  const dotenv_1 = require("dotenv");
4
6
  const auth_service_1 = require("../../services/auth-service");
5
7
  const credential_prompter_1 = require("../../services/credential-prompter");
6
8
  const oauth_browser_service_1 = require("../../services/oauth-browser-service");
7
9
  const global_1 = require("../../config/global");
10
+ const settings_1 = require("../../config/settings");
8
11
  const url_utils_1 = require("../../utils/url-utils");
12
+ /**
13
+ * Picks how to log in from what the user passed. The browser OAuth flow is the default, but it is
14
+ * skipped when the user asked to be prompted (`--prompt`), when it is disabled through the
15
+ * `disableBrowserLogin` setting or the `FCODE_DISABLE_BROWSER_LOGIN` env var (headless machines,
16
+ * CI, remote shells), or when credentials that identify another method were given.
17
+ */
18
+ function resolveLoginMethod(options) {
19
+ if (options.accessToken) {
20
+ return 'accessToken';
21
+ }
22
+ if (options.prompt ||
23
+ options.browserLoginDisabled ||
24
+ options.email ||
25
+ options.password ||
26
+ (options.clientId && options.clientSecret)) {
27
+ return 'credentials';
28
+ }
29
+ return 'browser';
30
+ }
31
+ async function isBrowserLoginDisabled(globalConfig = new global_1.default()) {
32
+ return (process.env.FCODE_DISABLE_BROWSER_LOGIN === 'true' ||
33
+ (await globalConfig.getSetting(settings_1.SettingKeys.DISABLE_BROWSER_LOGIN)) === 'true');
34
+ }
9
35
  const auth = async function ({ argv }) {
10
36
  const url = argv[0] || global_1.FCODE_CLOUD_URL;
11
37
  const authUrl = argv[1] || (0, url_utils_1.buildAuthUrl)(url);
12
38
  const isAuthenticated = await auth_service_1.default.isAuthenticated(url);
13
39
  if (!isAuthenticated) {
14
40
  (0, dotenv_1.config)();
15
- const accessToken = argv[6];
16
- const prompt = argv[7];
17
- if (accessToken) {
41
+ const [, , email, password, clientId, clientSecret, accessToken, prompt] = argv;
42
+ const method = resolveLoginMethod({
43
+ email,
44
+ password,
45
+ clientId,
46
+ clientSecret,
47
+ accessToken,
48
+ prompt: Boolean(prompt),
49
+ browserLoginDisabled: await isBrowserLoginDisabled(),
50
+ });
51
+ if (method === 'accessToken') {
18
52
  await auth_service_1.default.login({ url, authUrl, accessToken });
19
53
  }
20
- else if (prompt || argv[2] || argv[3] || (argv[4] && argv[5])) {
21
- await credential_prompter_1.default.prompt({ url, authUrl, email: argv[2], password: argv[3], clientId: argv[4], clientSecret: argv[5] });
54
+ else if (method === 'credentials') {
55
+ await credential_prompter_1.default.prompt({ url, authUrl, email, password, clientId, clientSecret });
22
56
  }
23
57
  else {
24
- await (0, oauth_browser_service_1.loginViaBrowser)(url, authUrl, argv[4]);
58
+ await (0, oauth_browser_service_1.loginViaBrowser)(url, authUrl, clientId);
25
59
  }
26
60
  }
27
61
  };