@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 +31 -8
- package/dist/commands/app/push.d.ts +13 -0
- package/dist/commands/app/push.js +147 -0
- package/dist/commands/login.js +2 -1
- package/dist/commands/team/clone.js +10 -11
- package/dist/commands/team/pull.js +6 -6
- package/dist/commands/team/status.d.ts +2 -0
- package/dist/commands/team/status.js +53 -7
- package/dist/config/dev-team.d.ts +31 -0
- package/dist/config/dev-team.js +71 -4
- package/dist/config/settings.d.ts +1 -0
- package/dist/config/settings.js +6 -0
- package/dist/hooks/init/auth.d.ts +19 -0
- package/dist/hooks/init/auth.js +40 -6
- package/dist/services/app-metadata-files.d.ts +98 -0
- package/dist/services/app-metadata-files.js +363 -0
- package/dist/services/dashboard-client.d.ts +71 -0
- package/dist/services/dashboard-client.js +125 -10
- package/dist/services/dev-team-service.d.ts +32 -15
- package/dist/services/dev-team-service.js +108 -35
- package/oclif.manifest.json +38 -5
- 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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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;
|
package/dist/commands/login.js
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
53
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
// Written even when
|
|
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)(
|
|
29
|
+
if ((0, dev_team_service_1.reportOutcomes)(result)) {
|
|
30
30
|
process.exitCode = 1;
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
-
TeamPullCommand.description =
|
|
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,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
|
|
24
|
-
cli_1.default.log(color_1.default.cyan(`\n━━ ${
|
|
25
|
-
const workspaceDir = path.join(rootDir,
|
|
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(
|
|
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(
|
|
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}
|
|
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 =
|
|
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;
|
package/dist/config/dev-team.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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",
|