@factorialco/fcode-cli 3.1.0 → 3.3.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.
Files changed (51) hide show
  1. package/README.md +24 -2
  2. package/dist/commands/app/push.d.ts +13 -0
  3. package/dist/commands/app/push.js +147 -0
  4. package/dist/commands/i18n/status.js +9 -0
  5. package/dist/commands/login.js +2 -1
  6. package/dist/commands/modules/pull.js +5 -4
  7. package/dist/commands/modules/push.js +1 -1
  8. package/dist/commands/modules/status.js +9 -0
  9. package/dist/commands/processes/pull.js +5 -4
  10. package/dist/commands/processes/push.js +1 -1
  11. package/dist/commands/processes/status.js +9 -0
  12. package/dist/commands/runtime-env.js +2 -2
  13. package/dist/commands/team/clone.js +1 -1
  14. package/dist/commands/team/pull.js +3 -2
  15. package/dist/commands/team/status.d.ts +2 -0
  16. package/dist/commands/team/status.js +44 -2
  17. package/dist/config/dev-team.d.ts +15 -0
  18. package/dist/config/dev-team.js +54 -1
  19. package/dist/config/settings.d.ts +1 -0
  20. package/dist/config/settings.js +6 -0
  21. package/dist/core/commands/resource-command.d.ts +4 -1
  22. package/dist/core/commands/resource-command.js +9 -2
  23. package/dist/core/dependencies/javascript-env-dependencies.js +4 -4
  24. package/dist/core/dependencies/python-env-dependencies.js +5 -4
  25. package/dist/core/executors/execution-task.js +1 -1
  26. package/dist/core/executors/javascript-execution-task.js +21 -17
  27. package/dist/core/executors/python-execution-task.js +11 -10
  28. package/dist/core/resource-pusher.js +13 -7
  29. package/dist/core/types.d.ts +16 -3
  30. package/dist/core/types.js +31 -6
  31. package/dist/hooks/init/auth.d.ts +19 -0
  32. package/dist/hooks/init/auth.js +40 -6
  33. package/dist/repositories/locale.js +5 -3
  34. package/dist/repositories/process.js +4 -2
  35. package/dist/repositories/script-library.js +3 -2
  36. package/dist/runtime/libs/python/fcode/datastore.py +3 -1
  37. package/dist/services/app-metadata-files.d.ts +98 -0
  38. package/dist/services/app-metadata-files.js +363 -0
  39. package/dist/services/dashboard-client.d.ts +59 -0
  40. package/dist/services/dashboard-client.js +116 -10
  41. package/dist/services/dev-team-service.d.ts +4 -1
  42. package/dist/services/dev-team-service.js +31 -17
  43. package/dist/services/fcode-graphql-client.d.ts +26 -7
  44. package/dist/services/fcode-graphql-client.js +90 -41
  45. package/dist/services/skills-installer.js +2 -2
  46. package/dist/services/version-pull-reporter.d.ts +20 -0
  47. package/dist/services/version-pull-reporter.js +33 -0
  48. package/dist/utils/child-process.d.ts +26 -0
  49. package/dist/utils/child-process.js +60 -0
  50. package/oclif.manifest.json +35 -2
  51. package/package.json +4 -1
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.3.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;
@@ -7,6 +7,7 @@ const cli_1 = require("../../utils/cli");
7
7
  class I18nStatus extends i18n_command_1.default {
8
8
  async resourcesTableLog(resources) {
9
9
  const hasInherited = resources.some((resource) => resource.resolvingTeamSlug);
10
+ const hasDeleted = resources.some((resource) => resource.deletion);
10
11
  cli_1.cli.table(resources, {
11
12
  locale: {
12
13
  // The identifier, not the slug: an inherited file is tracked as `en.inherited`, and the
@@ -24,6 +25,14 @@ class I18nStatus extends i18n_command_1.default {
24
25
  header: 'inherited',
25
26
  },
26
27
  }),
28
+ ...(hasDeleted && {
29
+ deleted: {
30
+ get(resource) {
31
+ return (0, colorize_resource_1.colorizeByStatus)(resource.deletion ? `🪦 kept by ${resource.deletion.retainingVersionTags.join(', ')}` : '', resource.status);
32
+ },
33
+ header: 'deleted',
34
+ },
35
+ }),
27
36
  status: {
28
37
  get({ status }) {
29
38
  return (0, colorize_resource_1.colorizeByStatus)(status, status);
@@ -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
  };
@@ -4,13 +4,14 @@ const color_1 = require("@oclif/color");
4
4
  const modules_command_1 = require("../../core/commands/modules-command");
5
5
  const types_1 = require("../../core/types");
6
6
  const cli_1 = require("../../utils/cli");
7
+ const version_pull_reporter_1 = require("../../services/version-pull-reporter");
7
8
  class ModulesPull extends modules_command_1.default {
8
9
  async pullVersioned() {
9
10
  const scriptLibraries = (await this._resourceRepository.findAll()).filter((scriptLibrary) => scriptLibrary.id);
10
11
  const scriptLibraryRepository = this._resourceRepository;
11
- const allPromises = [];
12
+ const pulls = [];
12
13
  for (const scriptLibrary of scriptLibraries) {
13
- allPromises.push(this._versionedResourceQuery(scriptLibrary.id).then(async (remoteVersions) => {
14
+ pulls.push((0, version_pull_reporter_1.trackVersionPull)(scriptLibrary.slug, this._versionedResourceQuery(scriptLibrary.id).then(async (remoteVersions) => {
14
15
  const remoteVersionTags = new Set(remoteVersions.map((v) => v.tag));
15
16
  // Remove local versions that were deleted in remote
16
17
  for (const localTag of scriptLibraryRepository.getLocalVersionTags(scriptLibrary)) {
@@ -26,10 +27,10 @@ class ModulesPull extends modules_command_1.default {
26
27
  await scriptLibraryRepository.saveVersion(scriptLibrary, versionedScriptLibrary);
27
28
  }
28
29
  }
29
- }));
30
+ })));
30
31
  await new Promise((resolve) => setTimeout(resolve, 10));
31
32
  }
32
- await Promise.allSettled(allPromises);
33
+ await (0, version_pull_reporter_1.reportFailedVersionPulls)(pulls, 'module');
33
34
  }
34
35
  async run() {
35
36
  await super.pullResources();
@@ -6,7 +6,7 @@ const types_1 = require("../../core/types");
6
6
  const cli_1 = require("../../utils/cli");
7
7
  class ModulesPush extends modules_command_1.default {
8
8
  async pushVersioned() {
9
- const scriptLibraries = (await this._resourceRepository.findAll()).filter((process) => process.id);
9
+ const scriptLibraries = (await this._resourceRepository.findAll()).filter((scriptLibrary) => scriptLibrary.id && !scriptLibrary.deletion);
10
10
  const scriptLibraryRepository = this._resourceRepository;
11
11
  const allPromises = [];
12
12
  for (const scriptLibrary of scriptLibraries) {
@@ -7,6 +7,7 @@ const cli_1 = require("../../utils/cli");
7
7
  class ModulesStatus extends modules_command_1.default {
8
8
  async resourcesTableLog(resources) {
9
9
  const hasInherited = resources.some((resource) => resource.resolvingTeamSlug);
10
+ const hasDeleted = resources.some((resource) => resource.deletion);
10
11
  cli_1.cli.table(resources, {
11
12
  name: {
12
13
  get({ slug, status }) {
@@ -22,6 +23,14 @@ class ModulesStatus extends modules_command_1.default {
22
23
  header: 'inherited',
23
24
  },
24
25
  }),
26
+ ...(hasDeleted && {
27
+ deleted: {
28
+ get(resource) {
29
+ return (0, colorize_resource_1.colorizeByStatus)(resource.deletion ? `🪦 kept by ${resource.deletion.retainingVersionTags.join(', ')}` : '', resource.status);
30
+ },
31
+ header: 'deleted',
32
+ },
33
+ }),
25
34
  status: {
26
35
  get({ status }) {
27
36
  return (0, colorize_resource_1.colorizeByStatus)(status, status);
@@ -5,13 +5,14 @@ const processes_command_1 = require("../../core/commands/processes-command");
5
5
  const types_1 = require("../../core/types");
6
6
  const cli_1 = require("../../utils/cli");
7
7
  const settings_1 = require("../../config/settings");
8
+ const version_pull_reporter_1 = require("../../services/version-pull-reporter");
8
9
  class ProcessesPull extends processes_command_1.default {
9
10
  async pullVersioned() {
10
11
  const processes = await this.getRemoteResources();
11
12
  const processRepository = this._resourceRepository;
12
- const allPromises = [];
13
+ const pulls = [];
13
14
  for (const process of processes) {
14
- allPromises.push(this._versionedResourceQuery(process.id).then(async (remoteVersions) => {
15
+ pulls.push((0, version_pull_reporter_1.trackVersionPull)(process.slug, this._versionedResourceQuery(process.id).then(async (remoteVersions) => {
15
16
  const remoteVersionTags = new Set(remoteVersions.map((v) => v.tag));
16
17
  // Remove local versions that were deleted in remote
17
18
  for (const localTag of processRepository.getLocalVersionTags(process)) {
@@ -27,10 +28,10 @@ class ProcessesPull extends processes_command_1.default {
27
28
  await processRepository.saveVersion(process, versionedProcess);
28
29
  }
29
30
  }
30
- }));
31
+ })));
31
32
  await new Promise((resolve) => setTimeout(resolve, 10));
32
33
  }
33
- await Promise.allSettled(allPromises);
34
+ await (0, version_pull_reporter_1.reportFailedVersionPulls)(pulls, 'process');
34
35
  }
35
36
  async run() {
36
37
  await super.pullResources();
@@ -7,7 +7,7 @@ const cli_1 = require("../../utils/cli");
7
7
  const settings_1 = require("../../config/settings");
8
8
  class ProcessesPush extends processes_command_1.default {
9
9
  async pushVersioned() {
10
- const processes = (await this._resourceRepository.findAll()).filter((process) => process.id);
10
+ const processes = (await this._resourceRepository.findAll()).filter((process) => process.id && !process.deletion);
11
11
  const processRepository = this._resourceRepository;
12
12
  const allPromises = [];
13
13
  for (const process of processes) {
@@ -7,6 +7,7 @@ const cli_1 = require("../../utils/cli");
7
7
  class ProcessesStatus extends processes_command_1.default {
8
8
  async resourcesTableLog(resources) {
9
9
  const hasInherited = resources.some((resource) => resource.resolvingTeamSlug);
10
+ const hasDeleted = resources.some((resource) => resource.deletion);
10
11
  cli_1.cli.table(resources, {
11
12
  slug: {
12
13
  get(resource) {
@@ -40,6 +41,14 @@ class ProcessesStatus extends processes_command_1.default {
40
41
  header: 'inherited',
41
42
  },
42
43
  }),
44
+ ...(hasDeleted && {
45
+ deleted: {
46
+ get(resource) {
47
+ return (0, colorize_resource_1.colorizeByStatus)(resource.deletion ? `🪦 kept by ${resource.deletion.retainingVersionTags.join(', ')}` : '', resource.status);
48
+ },
49
+ header: 'deleted',
50
+ },
51
+ }),
43
52
  status: {
44
53
  get({ status }) {
45
54
  return (0, colorize_resource_1.colorizeByStatus)(status, status);
@@ -21,10 +21,10 @@ class RuntimeEnv extends core_1.Command {
21
21
  console.log('DEPENDENCIES_PATH=' + dependenciesPath);
22
22
  console.log('LIBRARY_PATH=' + libraryPath);
23
23
  if (language === 'PYTHON') {
24
- console.log('PYTHONPATH=' + [dependenciesPath, libraryPath, process.cwd()].join(':'));
24
+ console.log('PYTHONPATH=' + [dependenciesPath, libraryPath, process.cwd()].join(path.delimiter));
25
25
  }
26
26
  else {
27
- console.log('NODE_PATH=' + [dependenciesPath, libraryPath, process.cwd()].join(':'));
27
+ console.log('NODE_PATH=' + [dependenciesPath, libraryPath, process.cwd()].join(path.delimiter));
28
28
  }
29
29
  }
30
30
  }
@@ -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',