@factorialco/fcode-cli 3.0.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.0.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)
@@ -1247,7 +1269,7 @@ EXAMPLES
1247
1269
 
1248
1270
  ## `fcode team:clone [TEAMID]`
1249
1271
 
1250
- Clone every App of a development team into one folder — an App folder each, holding the App's development workspace
1272
+ Clone a development team into one folder — an App folder each, holding the App's development workspace, plus the team's global workspaces under global-workspaces/
1251
1273
 
1252
1274
  ```
1253
1275
  USAGE
@@ -1260,7 +1282,8 @@ FLAGS
1260
1282
  --skipSkillsSetup Skip installation of the Factorial Code agent skills
1261
1283
 
1262
1284
  DESCRIPTION
1263
- Clone every App of a development team into one folder — an App folder each, holding the App's development workspace
1285
+ Clone a development team into one folder — an App folder each, holding the App's development workspace, plus the
1286
+ team's global workspaces under global-workspaces/
1264
1287
 
1265
1288
  EXAMPLES
1266
1289
  $ fcode team:clone
@@ -1270,14 +1293,14 @@ EXAMPLES
1270
1293
 
1271
1294
  ## `fcode team:pull`
1272
1295
 
1273
- Pull every App of the development team folder: clones Apps added since the last run and pulls each App's workspace
1296
+ Pull the development team folder: clones Apps and global workspaces added since the last run and pulls every workspace
1274
1297
 
1275
1298
  ```
1276
1299
  USAGE
1277
1300
  $ fcode team:pull
1278
1301
 
1279
1302
  DESCRIPTION
1280
- Pull every App of the development team folder: clones Apps added since the last run and pulls each App's workspace
1303
+ Pull the development team folder: clones Apps and global workspaces added since the last run and pulls every workspace
1281
1304
 
1282
1305
  EXAMPLES
1283
1306
  $ fcode team:pull
@@ -1285,14 +1308,14 @@ EXAMPLES
1285
1308
 
1286
1309
  ## `fcode team:status`
1287
1310
 
1288
- Show what differs from the cloud in every App workspace of the development team folder
1311
+ Show what differs from the cloud in every workspace of the development team folder
1289
1312
 
1290
1313
  ```
1291
1314
  USAGE
1292
1315
  $ fcode team:status
1293
1316
 
1294
1317
  DESCRIPTION
1295
- Show what differs from the cloud in every App workspace of the development team folder
1318
+ Show what differs from the cloud in every workspace of the development team folder
1296
1319
 
1297
1320
  EXAMPLES
1298
1321
  $ fcode team:status
@@ -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
  };
@@ -26,9 +26,9 @@ class TeamCloneCommand extends core_1.Command {
26
26
  if (!team) {
27
27
  return;
28
28
  }
29
- const apps = await client.listTeamApps(team.id);
30
- if (apps.length === 0) {
31
- cli_1.default.log(`ℹ️ '${team.name}' has no Apps yet.`);
29
+ const remote = await (0, dev_team_service_1.fetchTeamRemote)(client, team.id);
30
+ if (remote.apps.length === 0 && remote.globalWorkspaces.length === 0) {
31
+ cli_1.default.log(`ℹ️ '${team.name}' has no Apps or global workspaces yet.`);
32
32
  return;
33
33
  }
34
34
  const rootDir = path.resolve((0, slugify_1.slugifyOr)(team.name, team.id));
@@ -39,26 +39,25 @@ class TeamCloneCommand extends core_1.Command {
39
39
  suggestions: [`cd ${path.basename(rootDir)}`, 'fcode team:pull'],
40
40
  });
41
41
  }
42
- cli_1.default.log(color_1.default.cyan(`\n🏢 cloning ${apps.length} App(s) of '${team.name}'...`));
42
+ cli_1.default.log(color_1.default.cyan(`\n🏢 cloning ${remote.apps.length} App(s) and ${remote.globalWorkspaces.length} global workspace(s) of '${team.name}'...`));
43
43
  const metadata = {
44
44
  developmentTeam: { id: team.id, name: team.name },
45
45
  cloudUrl,
46
46
  apps: {},
47
+ globalWorkspaces: {},
47
48
  };
48
49
  // Written before anything is cloned, so a run interrupted halfway still leaves a folder
49
50
  // `fcode team:pull` recognises and can finish.
50
51
  const config = new dev_team_1.default(rootDir);
51
52
  config.write(metadata);
52
- // Skills next: each App links to them as it lands, so an install that fails leaves every
53
- // App cloned and only the links missing.
53
+ // Skills next: each workspace links to them as it lands, so an install that fails leaves
54
+ // everything cloned and only the links missing.
54
55
  if (!(await this.skipSkills(flags.skipSkillsSetup))) {
55
56
  await (0, skills_installer_1.installSkillsAt)(rootDir);
56
57
  }
57
- const plan = (0, dev_team_service_1.planAppSync)(metadata, apps);
58
- const outcomes = await (0, dev_team_service_1.syncApps)(rootDir, metadata, plan);
58
+ const result = await (0, dev_team_service_1.syncTeam)(rootDir, metadata, remote, client);
59
59
  config.write(metadata);
60
- const failed = (0, dev_team_service_1.reportOutcomes)(outcomes, plan.removed);
61
- if (failed) {
60
+ if ((0, dev_team_service_1.reportOutcomes)(result)) {
62
61
  process.exitCode = 1;
63
62
  cli_1.default.log(`\n⚠️ '${team.name}' cloned with errors — rerun 'fcode team:pull' inside the folder to retry.\n`);
64
63
  }
@@ -74,7 +73,7 @@ class TeamCloneCommand extends core_1.Command {
74
73
  return (await new global_1.default().getSetting(settings_1.SettingKeys.SKIP_SKILLS_SETUP)) === 'true';
75
74
  }
76
75
  }
77
- TeamCloneCommand.description = "Clone every App of a development team into one folder — an App folder each, holding the App's development workspace";
76
+ TeamCloneCommand.description = "Clone a development team into one folder — an App folder each, holding the App's development workspace, plus the team's global workspaces under global-workspaces/";
78
77
  TeamCloneCommand.examples = ['$ fcode team:clone', '$ fcode team:clone 0f9c1a2b-3d4e-5f60-8a1b-2c3d4e5f6071'];
79
78
  TeamCloneCommand.args = {
80
79
  teamId: core_1.Args.string({
@@ -20,17 +20,17 @@ 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 apps = await new dashboard_client_1.default(metadata.cloudUrl).listTeamApps(developmentTeam.id);
24
- const plan = (0, dev_team_service_1.planAppSync)(metadata, apps);
25
- const outcomes = await (0, dev_team_service_1.syncApps)(rootDir, metadata, plan);
26
- // Written even when an App failed, so the ones that did land are recorded and a rerun
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);
26
+ // Written even when a workspace failed, so the ones that did land are recorded and a rerun
27
27
  // only retries what is still missing.
28
28
  config.write(metadata);
29
- if ((0, dev_team_service_1.reportOutcomes)(outcomes, plan.removed)) {
29
+ if ((0, dev_team_service_1.reportOutcomes)(result)) {
30
30
  process.exitCode = 1;
31
31
  }
32
32
  }
33
33
  }
34
- TeamPullCommand.description = "Pull every App of the development team folder: clones Apps added since the last run and pulls each App's workspace";
34
+ TeamPullCommand.description = 'Pull the development team folder: clones Apps and global workspaces added since the last run and pulls every workspace';
35
35
  TeamPullCommand.examples = ['$ fcode team:pull'];
36
36
  exports.default = TeamPullCommand;
@@ -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,29 +29,65 @@ 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);
34
+ const checkouts = [
35
+ ...Object.values(metadata.globalWorkspaces).map(({ name, slug }) => ({ name, folder: (0, dev_team_1.globalWorkspaceFolder)(slug) })),
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
+ })),
41
+ ];
22
42
  const failed = [];
23
- for (const app of Object.values(metadata.apps)) {
24
- cli_1.default.log(color_1.default.cyan(`\n━━ ${app.name} (${app.folder}) ━━`));
25
- const workspaceDir = path.join(rootDir, app.folder, dev_team_1.APP_WORKSPACE_DIR);
43
+ for (const { name, folder, app } of checkouts) {
44
+ cli_1.default.log(color_1.default.cyan(`\n━━ ${name} (${folder}) ━━`));
45
+ const workspaceDir = path.join(rootDir, folder);
26
46
  if (!fs.pathExistsSync(workspaceDir)) {
27
47
  cli_1.default.log(color_1.default.yellow('⚠️ not cloned yet — run fcode team:pull'));
28
- failed.push(app.folder);
48
+ failed.push(folder);
29
49
  continue;
30
50
  }
51
+ if (app) {
52
+ await this.reportMetadata(client, app, remoteApps.get(app.id));
53
+ }
31
54
  try {
32
55
  await (0, workspace_cloner_1.runInWorkspace)(workspaceDir, () => status_1.default.run([]));
33
56
  }
34
57
  catch (error) {
35
58
  cli_1.default.log(color_1.default.red(`❌ ${error.message}`));
36
- failed.push(app.folder);
59
+ failed.push(folder);
37
60
  }
38
61
  }
39
62
  if (failed.length > 0) {
40
63
  process.exitCode = 1;
41
- cli_1.default.log(color_1.default.red(`\n${failed.length} App(s) could not be checked: ${failed.join(', ')}`));
64
+ cli_1.default.log(color_1.default.red(`\n${failed.length} workspace(s) could not be checked: ${failed.join(', ')}`));
65
+ }
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}`));
42
88
  }
43
89
  }
44
90
  }
45
- TeamStatusCommand.description = "Show what differs from the cloud in every App workspace of the development team folder";
91
+ TeamStatusCommand.description = 'Show what differs from the cloud in every workspace of the development team folder';
46
92
  TeamStatusCommand.examples = ['$ fcode team:status'];
47
93
  exports.default = TeamStatusCommand;
@@ -5,6 +5,12 @@ import { CLIError } from '../core/errors';
5
5
  * it at another workspace with `fcode remote:add` (dev → prod) must not make its name a lie.
6
6
  */
7
7
  export declare const APP_WORKSPACE_DIR = "app";
8
+ /**
9
+ * The global workspaces the team owns sit together under this folder, one checkout per workspace
10
+ * named after its slug. A global workspace is one platform team with a fixed slug, so — unlike an
11
+ * App — there is no name to derive a folder from and nothing to re-point the checkout at.
12
+ */
13
+ export declare const GLOBAL_WORKSPACES_DIR = "global-workspaces";
8
14
  export interface DevTeamApp {
9
15
  name: string;
10
16
  description: string | null;
@@ -12,6 +18,16 @@ export interface DevTeamApp {
12
18
  folder: string;
13
19
  /** Workspace the checkout was cloned from. The directory itself is always `APP_WORKSPACE_DIR`. */
14
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;
25
+ }
26
+ export interface DevTeamGlobalWorkspace {
27
+ name: string;
28
+ description: string;
29
+ /** Platform team slug; also the checkout's folder name under `GLOBAL_WORKSPACES_DIR`. */
30
+ slug: string;
15
31
  }
16
32
  export interface DevTeamMetadata {
17
33
  developmentTeam: {
@@ -21,10 +37,17 @@ export interface DevTeamMetadata {
21
37
  cloudUrl: string;
22
38
  /** Keyed by App id, so a renamed App keeps its folder. */
23
39
  apps: Record<string, DevTeamApp>;
40
+ /** Keyed by global workspace id. */
41
+ globalWorkspaces: Record<string, DevTeamGlobalWorkspace>;
24
42
  }
43
+ /** Where a global workspace is checked out, relative to the team root. */
44
+ export declare function globalWorkspaceFolder(slug: string): string;
25
45
  export declare class NotDevTeamFolder extends CLIError {
26
46
  constructor();
27
47
  }
48
+ export declare class NotAppFolder extends CLIError {
49
+ constructor();
50
+ }
28
51
  export default class DevTeamConfig {
29
52
  private readonly rootDir;
30
53
  constructor(rootDir?: string);
@@ -46,3 +69,11 @@ export interface AppSettings {
46
69
  */
47
70
  export declare const APP_SETTINGS_FILENAME = "settings.json";
48
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,7 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.APP_SETTINGS_FILENAME = exports.NotDevTeamFolder = 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
+ exports.globalWorkspaceFolder = globalWorkspaceFolder;
4
5
  exports.writeAppSettings = writeAppSettings;
6
+ exports.readAppSettings = readAppSettings;
7
+ exports.findAppFolder = findAppFolder;
8
+ exports.findDevTeamRoot = findDevTeamRoot;
5
9
  const fs = require("fs-extra");
6
10
  const path = require("node:path");
7
11
  const errors_1 = require("../core/errors");
@@ -10,8 +14,8 @@ const errors_1 = require("../core/errors");
10
14
  * and what `team:pull` / `team:status` recognise the folder by.
11
15
  *
12
16
  * Not to be confused with a workspace's `settings.json` (the file that used to be called
13
- * `team.json`): this one describes the *development team* — the humans and the Apps they own —
14
- * and lives one level above the workspaces, inside `.fcode/`.
17
+ * `team.json`): this one describes the *development team* — the humans, the Apps they own and the
18
+ * global workspaces they maintain — and lives one level above the workspaces, inside `.fcode/`.
15
19
  */
16
20
  const DEV_TEAM_CONFIG_PATH = path.join('.fcode', 'team.json');
17
21
  /**
@@ -20,6 +24,16 @@ const DEV_TEAM_CONFIG_PATH = path.join('.fcode', 'team.json');
20
24
  * it at another workspace with `fcode remote:add` (dev → prod) must not make its name a lie.
21
25
  */
22
26
  exports.APP_WORKSPACE_DIR = 'app';
27
+ /**
28
+ * The global workspaces the team owns sit together under this folder, one checkout per workspace
29
+ * named after its slug. A global workspace is one platform team with a fixed slug, so — unlike an
30
+ * App — there is no name to derive a folder from and nothing to re-point the checkout at.
31
+ */
32
+ exports.GLOBAL_WORKSPACES_DIR = 'global-workspaces';
33
+ /** Where a global workspace is checked out, relative to the team root. */
34
+ function globalWorkspaceFolder(slug) {
35
+ return path.join(exports.GLOBAL_WORKSPACES_DIR, slug);
36
+ }
23
37
  class NotDevTeamFolder extends errors_1.CLIError {
24
38
  constructor() {
25
39
  super('⚠️ Current directory is not a Factorial Code development team folder.', {
@@ -30,6 +44,16 @@ class NotDevTeamFolder extends errors_1.CLIError {
30
44
  }
31
45
  }
32
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;
33
57
  class DevTeamConfig {
34
58
  constructor(rootDir = process.cwd()) {
35
59
  this.rootDir = rootDir;
@@ -44,7 +68,10 @@ class DevTeamConfig {
44
68
  if (!this.exists()) {
45
69
  throw new NotDevTeamFolder();
46
70
  }
47
- return fs.readJsonSync(this.configPath);
71
+ const metadata = fs.readJsonSync(this.configPath);
72
+ // A team folder cloned before global workspaces were part of it has no entry; the next
73
+ // team:pull then clones them the way it clones Apps added since the last run.
74
+ return { ...metadata, globalWorkspaces: metadata.globalWorkspaces ?? {} };
48
75
  }
49
76
  write(metadata) {
50
77
  fs.outputJsonSync(this.configPath, metadata, { spaces: 2 });
@@ -60,3 +87,43 @@ exports.APP_SETTINGS_FILENAME = 'settings.json';
60
87
  function writeAppSettings(appDir, app) {
61
88
  fs.outputJsonSync(path.join(appDir, exports.APP_SETTINGS_FILENAME), app, { spaces: 2 });
62
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",