@factorialco/fcode-cli 3.0.0 → 3.1.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.1.0 linux-x64 node-v22.22.3
24
24
  $ fcode --help [COMMAND]
25
25
  USAGE
26
26
  $ fcode COMMAND
@@ -1247,7 +1247,7 @@ EXAMPLES
1247
1247
 
1248
1248
  ## `fcode team:clone [TEAMID]`
1249
1249
 
1250
- Clone every App of a development team into one folder — an App folder each, holding the App's development workspace
1250
+ 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
1251
 
1252
1252
  ```
1253
1253
  USAGE
@@ -1260,7 +1260,8 @@ FLAGS
1260
1260
  --skipSkillsSetup Skip installation of the Factorial Code agent skills
1261
1261
 
1262
1262
  DESCRIPTION
1263
- Clone every App of a development team into one folder — an App folder each, holding the App's development workspace
1263
+ Clone a development team into one folder — an App folder each, holding the App's development workspace, plus the
1264
+ team's global workspaces under global-workspaces/
1264
1265
 
1265
1266
  EXAMPLES
1266
1267
  $ fcode team:clone
@@ -1270,14 +1271,14 @@ EXAMPLES
1270
1271
 
1271
1272
  ## `fcode team:pull`
1272
1273
 
1273
- Pull every App of the development team folder: clones Apps added since the last run and pulls each App's workspace
1274
+ Pull the development team folder: clones Apps and global workspaces added since the last run and pulls every workspace
1274
1275
 
1275
1276
  ```
1276
1277
  USAGE
1277
1278
  $ fcode team:pull
1278
1279
 
1279
1280
  DESCRIPTION
1280
- Pull every App of the development team folder: clones Apps added since the last run and pulls each App's workspace
1281
+ Pull the development team folder: clones Apps and global workspaces added since the last run and pulls every workspace
1281
1282
 
1282
1283
  EXAMPLES
1283
1284
  $ fcode team:pull
@@ -1285,14 +1286,14 @@ EXAMPLES
1285
1286
 
1286
1287
  ## `fcode team:status`
1287
1288
 
1288
- Show what differs from the cloud in every App workspace of the development team folder
1289
+ Show what differs from the cloud in every workspace of the development team folder
1289
1290
 
1290
1291
  ```
1291
1292
  USAGE
1292
1293
  $ fcode team:status
1293
1294
 
1294
1295
  DESCRIPTION
1295
- Show what differs from the cloud in every App workspace of the development team folder
1296
+ Show what differs from the cloud in every workspace of the development team folder
1296
1297
 
1297
1298
  EXAMPLES
1298
1299
  $ fcode team:status
@@ -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);
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,16 @@ 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 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);
25
+ // Written even when a workspace failed, so the ones that did land are recorded and a rerun
27
26
  // only retries what is still missing.
28
27
  config.write(metadata);
29
- if ((0, dev_team_service_1.reportOutcomes)(outcomes, plan.removed)) {
28
+ if ((0, dev_team_service_1.reportOutcomes)(result)) {
30
29
  process.exitCode = 1;
31
30
  }
32
31
  }
33
32
  }
34
- TeamPullCommand.description = "Pull every App of the development team folder: clones Apps added since the last run and pulls each App's workspace";
33
+ TeamPullCommand.description = 'Pull the development team folder: clones Apps and global workspaces added since the last run and pulls every workspace';
35
34
  TeamPullCommand.examples = ['$ fcode team:pull'];
36
35
  exports.default = TeamPullCommand;
@@ -19,13 +19,17 @@ class TeamStatusCommand extends core_1.Command {
19
19
  await this.parse(TeamStatusCommand);
20
20
  const rootDir = process.cwd();
21
21
  const metadata = new dev_team_1.default(rootDir).read();
22
+ const checkouts = [
23
+ ...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) })),
25
+ ];
22
26
  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);
27
+ for (const { name, folder } of checkouts) {
28
+ cli_1.default.log(color_1.default.cyan(`\n━━ ${name} (${folder}) ━━`));
29
+ const workspaceDir = path.join(rootDir, folder);
26
30
  if (!fs.pathExistsSync(workspaceDir)) {
27
31
  cli_1.default.log(color_1.default.yellow('⚠️ not cloned yet — run fcode team:pull'));
28
- failed.push(app.folder);
32
+ failed.push(folder);
29
33
  continue;
30
34
  }
31
35
  try {
@@ -33,15 +37,15 @@ class TeamStatusCommand extends core_1.Command {
33
37
  }
34
38
  catch (error) {
35
39
  cli_1.default.log(color_1.default.red(`❌ ${error.message}`));
36
- failed.push(app.folder);
40
+ failed.push(folder);
37
41
  }
38
42
  }
39
43
  if (failed.length > 0) {
40
44
  process.exitCode = 1;
41
- cli_1.default.log(color_1.default.red(`\n${failed.length} App(s) could not be checked: ${failed.join(', ')}`));
45
+ cli_1.default.log(color_1.default.red(`\n${failed.length} workspace(s) could not be checked: ${failed.join(', ')}`));
42
46
  }
43
47
  }
44
48
  }
45
- TeamStatusCommand.description = "Show what differs from the cloud in every App workspace of the development team folder";
49
+ TeamStatusCommand.description = 'Show what differs from the cloud in every workspace of the development team folder';
46
50
  TeamStatusCommand.examples = ['$ fcode team:status'];
47
51
  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;
@@ -13,6 +19,12 @@ export interface DevTeamApp {
13
19
  /** Workspace the checkout was cloned from. The directory itself is always `APP_WORKSPACE_DIR`. */
14
20
  devWorkspaceSlug: string;
15
21
  }
22
+ export interface DevTeamGlobalWorkspace {
23
+ name: string;
24
+ description: string;
25
+ /** Platform team slug; also the checkout's folder name under `GLOBAL_WORKSPACES_DIR`. */
26
+ slug: string;
27
+ }
16
28
  export interface DevTeamMetadata {
17
29
  developmentTeam: {
18
30
  id: string;
@@ -21,7 +33,11 @@ export interface DevTeamMetadata {
21
33
  cloudUrl: string;
22
34
  /** Keyed by App id, so a renamed App keeps its folder. */
23
35
  apps: Record<string, DevTeamApp>;
36
+ /** Keyed by global workspace id. */
37
+ globalWorkspaces: Record<string, DevTeamGlobalWorkspace>;
24
38
  }
39
+ /** Where a global workspace is checked out, relative to the team root. */
40
+ export declare function globalWorkspaceFolder(slug: string): string;
25
41
  export declare class NotDevTeamFolder extends CLIError {
26
42
  constructor();
27
43
  }
@@ -1,6 +1,7 @@
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.NotDevTeamFolder = exports.GLOBAL_WORKSPACES_DIR = exports.APP_WORKSPACE_DIR = void 0;
4
+ exports.globalWorkspaceFolder = globalWorkspaceFolder;
4
5
  exports.writeAppSettings = writeAppSettings;
5
6
  const fs = require("fs-extra");
6
7
  const path = require("node:path");
@@ -10,8 +11,8 @@ const errors_1 = require("../core/errors");
10
11
  * and what `team:pull` / `team:status` recognise the folder by.
11
12
  *
12
13
  * 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/`.
14
+ * `team.json`): this one describes the *development team* — the humans, the Apps they own and the
15
+ * global workspaces they maintain — and lives one level above the workspaces, inside `.fcode/`.
15
16
  */
16
17
  const DEV_TEAM_CONFIG_PATH = path.join('.fcode', 'team.json');
17
18
  /**
@@ -20,6 +21,16 @@ const DEV_TEAM_CONFIG_PATH = path.join('.fcode', 'team.json');
20
21
  * it at another workspace with `fcode remote:add` (dev → prod) must not make its name a lie.
21
22
  */
22
23
  exports.APP_WORKSPACE_DIR = 'app';
24
+ /**
25
+ * The global workspaces the team owns sit together under this folder, one checkout per workspace
26
+ * named after its slug. A global workspace is one platform team with a fixed slug, so — unlike an
27
+ * App — there is no name to derive a folder from and nothing to re-point the checkout at.
28
+ */
29
+ exports.GLOBAL_WORKSPACES_DIR = 'global-workspaces';
30
+ /** Where a global workspace is checked out, relative to the team root. */
31
+ function globalWorkspaceFolder(slug) {
32
+ return path.join(exports.GLOBAL_WORKSPACES_DIR, slug);
33
+ }
23
34
  class NotDevTeamFolder extends errors_1.CLIError {
24
35
  constructor() {
25
36
  super('⚠️ Current directory is not a Factorial Code development team folder.', {
@@ -44,7 +55,10 @@ class DevTeamConfig {
44
55
  if (!this.exists()) {
45
56
  throw new NotDevTeamFolder();
46
57
  }
47
- return fs.readJsonSync(this.configPath);
58
+ const metadata = fs.readJsonSync(this.configPath);
59
+ // A team folder cloned before global workspaces were part of it has no entry; the next
60
+ // team:pull then clones them the way it clones Apps added since the last run.
61
+ return { ...metadata, globalWorkspaces: metadata.globalWorkspaces ?? {} };
48
62
  }
49
63
  write(metadata) {
50
64
  fs.outputJsonSync(this.configPath, metadata, { spaces: 2 });
@@ -8,6 +8,16 @@ export interface DevelopmentTeamApp {
8
8
  name: string;
9
9
  description: string | null;
10
10
  }
11
+ /**
12
+ * A base workspace the team owns (`base-app` and the like), as opposed to an App's `dev-` workspace.
13
+ * The slug is the platform team slug itself: fixed, filesystem-safe, and unique.
14
+ */
15
+ export interface DevelopmentTeamGlobalWorkspace {
16
+ id: string;
17
+ name: string;
18
+ slug: string;
19
+ description: string;
20
+ }
11
21
  /**
12
22
  * `https://code.factorialhr.com/platform` → `https://code.factorialhr.com/dashboard/api/v1`.
13
23
  * The dashboard is served under `/dashboard` on the same origin as the platform; environments
@@ -24,6 +34,8 @@ export default class DashboardClient {
24
34
  listDevelopmentTeams(): Promise<DevelopmentTeam[]>;
25
35
  /** The Apps owned by a development team, narrowed by the API to the ones the session can see. */
26
36
  listTeamApps(teamId: string): Promise<DevelopmentTeamApp[]>;
37
+ /** The global workspaces owned by a development team. */
38
+ listTeamGlobalWorkspaces(teamId: string): Promise<DevelopmentTeamGlobalWorkspace[]>;
27
39
  private getAllPages;
28
40
  private get;
29
41
  private headers;
@@ -45,6 +45,15 @@ class DashboardClient {
45
45
  });
46
46
  return items.map(({ id, name, description }) => ({ id, name, description }));
47
47
  }
48
+ /** The global workspaces owned by a development team. */
49
+ async listTeamGlobalWorkspaces(teamId) {
50
+ const items = await this.getAllPages('/global-workspaces', {
51
+ owner_development_team_id: teamId,
52
+ sort_by: 'slug',
53
+ sort_dir: 'asc',
54
+ });
55
+ return items.map(({ id, name, slug, description }) => ({ id, name, slug, description }));
56
+ }
48
57
  async getAllPages(path, query = {}) {
49
58
  const items = [];
50
59
  let offset = 0;
@@ -1,18 +1,26 @@
1
- import DashboardClient, { DevelopmentTeam, DevelopmentTeamApp } from './dashboard-client';
2
- import { DevTeamApp, DevTeamMetadata } from '../config/dev-team';
3
- /** What a team folder has to do to match the team as the dashboard currently describes it. */
4
- export interface AppSyncPlan {
1
+ import DashboardClient, { DevelopmentTeam, DevelopmentTeamApp, DevelopmentTeamGlobalWorkspace } from './dashboard-client';
2
+ import { DevTeamMetadata } from '../config/dev-team';
3
+ /** What the dashboard currently says the team holds. */
4
+ export interface DevTeamRemote {
5
+ apps: DevelopmentTeamApp[];
6
+ globalWorkspaces: DevelopmentTeamGlobalWorkspace[];
7
+ }
8
+ export declare function fetchTeamRemote(client: DashboardClient, teamId: string): Promise<DevTeamRemote>;
9
+ /** What a team folder has to do to make one kind of entry match the dashboard. */
10
+ export interface SyncPlan<Remote, Recorded> {
5
11
  /** In the team, with no folder yet — cloned. */
6
- toClone: DevelopmentTeamApp[];
12
+ toClone: Remote[];
7
13
  /** In the team and already cloned — refreshed and pulled. */
8
- toRefresh: DevelopmentTeamApp[];
14
+ toRefresh: Remote[];
9
15
  /** Recorded locally but no longer in the team — reported, never deleted. */
10
16
  removed: Array<{
11
17
  id: string;
12
- app: DevTeamApp;
18
+ recorded: Recorded;
13
19
  }>;
14
20
  }
15
- export declare function planAppSync(metadata: DevTeamMetadata, apps: DevelopmentTeamApp[]): AppSyncPlan;
21
+ export declare function planSync<Remote extends {
22
+ id: string;
23
+ }, Recorded>(recorded: Record<string, Recorded>, remote: Remote[]): SyncPlan<Remote, Recorded>;
16
24
  /**
17
25
  * The folder an App gets inside the team root. Names are free text and two Apps may well slugify
18
26
  * the same, so the first claimant keeps the plain slug and the next ones are suffixed. Only ever
@@ -20,20 +28,26 @@ export declare function planAppSync(metadata: DevTeamMetadata, apps: Development
20
28
  * a renamed App keeps the folder it was cloned into.
21
29
  */
22
30
  export declare function assignFolder(app: DevelopmentTeamApp, metadata: DevTeamMetadata): string;
23
- export interface AppSyncOutcome {
31
+ export interface TeamSyncOutcome {
24
32
  name: string;
25
33
  folder: string;
26
34
  action: 'cloned' | 'pulled';
27
35
  error?: Error;
28
36
  }
37
+ export interface TeamSyncResult {
38
+ outcomes: TeamSyncOutcome[];
39
+ /** Folders recorded locally whose App or global workspace has left the team. */
40
+ removedFolders: string[];
41
+ }
29
42
  /**
30
- * Applies the plan, one App at a time, recording the metadata of each App that lands. A failing
31
- * App is reported and the run carries on: with a dozen Apps, one bad workspace must not cost the
32
- * other eleven.
43
+ * Brings the team folder in line with the dashboard — global workspaces first, since the Apps
44
+ * inherit from them, then the Apps — one workspace at a time, recording the metadata of each one
45
+ * that lands. A failing workspace is reported and the run carries on: with a dozen Apps, one bad
46
+ * workspace must not cost the other eleven.
33
47
  */
34
- export declare function syncApps(rootDir: string, metadata: DevTeamMetadata, plan: AppSyncPlan): Promise<AppSyncOutcome[]>;
35
- /** Prints the per-App result and reports whether anything failed. */
36
- export declare function reportOutcomes(outcomes: AppSyncOutcome[], removed: AppSyncPlan['removed']): boolean;
48
+ export declare function syncTeam(rootDir: string, metadata: DevTeamMetadata, remote: DevTeamRemote): Promise<TeamSyncResult>;
49
+ /** Prints the per-workspace result and reports whether anything failed. */
50
+ export declare function reportOutcomes({ outcomes, removedFolders }: TeamSyncResult): boolean;
37
51
  /**
38
52
  * The team to operate on: the one named, the only one the session belongs to, or — when there are
39
53
  * several — none, after listing them so the user can pass an id.
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.planAppSync = planAppSync;
3
+ exports.fetchTeamRemote = fetchTeamRemote;
4
+ exports.planSync = planSync;
4
5
  exports.assignFolder = assignFolder;
5
- exports.syncApps = syncApps;
6
+ exports.syncTeam = syncTeam;
6
7
  exports.reportOutcomes = reportOutcomes;
7
8
  exports.resolveTeam = resolveTeam;
8
9
  const fs = require("fs-extra");
@@ -15,14 +16,18 @@ const dev_slug_1 = require("../utils/dev-slug");
15
16
  const workspace_cloner_1 = require("./workspace-cloner");
16
17
  const slugify_1 = require("../utils/slugify");
17
18
  const cli_1 = require("../utils/cli");
18
- function planAppSync(metadata, apps) {
19
- const remoteIds = new Set(apps.map(({ id }) => id));
19
+ async function fetchTeamRemote(client, teamId) {
20
+ const [apps, globalWorkspaces] = await Promise.all([client.listTeamApps(teamId), client.listTeamGlobalWorkspaces(teamId)]);
21
+ return { apps, globalWorkspaces };
22
+ }
23
+ function planSync(recorded, remote) {
24
+ const remoteIds = new Set(remote.map(({ id }) => id));
20
25
  return {
21
- toClone: apps.filter(({ id }) => metadata.apps[id] === undefined),
22
- toRefresh: apps.filter(({ id }) => metadata.apps[id] !== undefined),
23
- removed: Object.entries(metadata.apps)
26
+ toClone: remote.filter(({ id }) => recorded[id] === undefined),
27
+ toRefresh: remote.filter(({ id }) => recorded[id] !== undefined),
28
+ removed: Object.entries(recorded)
24
29
  .filter(([id]) => !remoteIds.has(id))
25
- .map(([id, app]) => ({ id, app })),
30
+ .map(([id, entry]) => ({ id, recorded: entry })),
26
31
  };
27
32
  }
28
33
  /**
@@ -44,9 +49,12 @@ function linkSkills(rootDir, appDir, workspaceDir) {
44
49
  (0, skills_installer_1.linkSkillsInto)(appDir, rootDir);
45
50
  (0, skills_installer_1.linkSkillsInto)(workspaceDir, rootDir);
46
51
  }
47
- /** A committed .gitignore, since each App folder is meant to become its own repository. */
48
- function writeAppGitIgnore(appDir) {
49
- const gitIgnorePath = path.join(appDir, '.gitignore');
52
+ /**
53
+ * A committed .gitignore keeping the skill links out of the repository: one in each App folder,
54
+ * since each is meant to become its own repository, and one over the global workspaces.
55
+ */
56
+ function writeSkillsGitIgnore(dir) {
57
+ const gitIgnorePath = path.join(dir, '.gitignore');
50
58
  if (fs.pathExistsSync(gitIgnorePath)) {
51
59
  return;
52
60
  }
@@ -65,7 +73,7 @@ async function cloneApp(rootDir, app, folder, metadata) {
65
73
  await (0, workspace_cloner_1.scaffoldWorkspace)(workspaceDir, { url: metadata.cloudUrl, slug });
66
74
  metadata.apps[app.id] = { name: app.name, description: app.description, folder, devWorkspaceSlug: slug };
67
75
  (0, dev_team_1.writeAppSettings)(appDir, { id: app.id, name: app.name, description: app.description });
68
- writeAppGitIgnore(appDir);
76
+ writeSkillsGitIgnore(appDir);
69
77
  linkSkills(rootDir, appDir, workspaceDir);
70
78
  await (0, workspace_cloner_1.runInWorkspace)(workspaceDir, () => pull_1.default.run([]));
71
79
  }
@@ -81,17 +89,30 @@ async function refreshApp(rootDir, cloudUrl, app, recorded) {
81
89
  // Name and description live in the dashboard; the local copy is a mirror refreshed on every
82
90
  // pull. There is deliberately no team:push — pushing happens inside a workspace.
83
91
  (0, dev_team_1.writeAppSettings)(appDir, { id: app.id, name: app.name, description: app.description });
84
- writeAppGitIgnore(appDir);
92
+ writeSkillsGitIgnore(appDir);
85
93
  recorded.name = app.name;
86
94
  recorded.description = app.description;
87
95
  linkSkills(rootDir, appDir, workspaceDir);
88
96
  await (0, workspace_cloner_1.runInWorkspace)(workspaceDir, () => pull_1.default.run([]));
89
97
  }
90
98
  /**
91
- * Applies the plan, one App at a time, recording the metadata of each App that lands. A failing
92
- * App is reported and the run carries on: with a dozen Apps, one bad workspace must not cost the
93
- * other eleven.
99
+ * Clones or refreshes one global workspace. Its slug is the platform team slug — fixed, unique and
100
+ * filesystem-safe — so the checkout goes straight to `global-workspaces/{slug}` with no folder to
101
+ * assign and no App-level settings to mirror. Recorded before the pull for the same reason as an
102
+ * App: a half-cloned checkout must read as cloned on the retry.
94
103
  */
104
+ async function syncGlobalWorkspace(rootDir, workspace, metadata) {
105
+ const folder = (0, dev_team_1.globalWorkspaceFolder)(workspace.slug);
106
+ const workspaceDir = path.join(rootDir, folder);
107
+ cli_1.default.log(color_1.default.cyan(`\n🌐 ${workspace.name} → ${folder}`));
108
+ if (!fs.pathExistsSync(path.join(workspaceDir, '.fcode'))) {
109
+ await (0, workspace_cloner_1.scaffoldWorkspace)(workspaceDir, { url: metadata.cloudUrl, slug: workspace.slug });
110
+ }
111
+ metadata.globalWorkspaces[workspace.id] = { name: workspace.name, description: workspace.description, slug: workspace.slug };
112
+ writeSkillsGitIgnore(path.join(rootDir, dev_team_1.GLOBAL_WORKSPACES_DIR));
113
+ (0, skills_installer_1.linkSkillsInto)(workspaceDir, rootDir);
114
+ await (0, workspace_cloner_1.runInWorkspace)(workspaceDir, () => pull_1.default.run([]));
115
+ }
95
116
  async function syncApps(rootDir, metadata, plan) {
96
117
  const outcomes = [];
97
118
  for (const app of plan.toClone) {
@@ -116,8 +137,46 @@ async function syncApps(rootDir, metadata, plan) {
116
137
  }
117
138
  return outcomes;
118
139
  }
119
- /** Prints the per-App result and reports whether anything failed. */
120
- function reportOutcomes(outcomes, removed) {
140
+ async function syncGlobalWorkspaces(rootDir, metadata, plan) {
141
+ const outcomes = [];
142
+ for (const [action, workspaces] of [
143
+ ['cloned', plan.toClone],
144
+ ['pulled', plan.toRefresh],
145
+ ]) {
146
+ for (const workspace of workspaces) {
147
+ const folder = (0, dev_team_1.globalWorkspaceFolder)(workspace.slug);
148
+ try {
149
+ await syncGlobalWorkspace(rootDir, workspace, metadata);
150
+ outcomes.push({ name: workspace.name, folder, action });
151
+ }
152
+ catch (error) {
153
+ outcomes.push({ name: workspace.name, folder, action, error: error });
154
+ }
155
+ }
156
+ }
157
+ return outcomes;
158
+ }
159
+ /**
160
+ * Brings the team folder in line with the dashboard — global workspaces first, since the Apps
161
+ * inherit from them, then the Apps — one workspace at a time, recording the metadata of each one
162
+ * that lands. A failing workspace is reported and the run carries on: with a dozen Apps, one bad
163
+ * workspace must not cost the other eleven.
164
+ */
165
+ async function syncTeam(rootDir, metadata, remote) {
166
+ const globalWorkspacePlan = planSync(metadata.globalWorkspaces, remote.globalWorkspaces);
167
+ const appPlan = planSync(metadata.apps, remote.apps);
168
+ const outcomes = [
169
+ ...(await syncGlobalWorkspaces(rootDir, metadata, globalWorkspacePlan)),
170
+ ...(await syncApps(rootDir, metadata, appPlan)),
171
+ ];
172
+ const removedFolders = [
173
+ ...globalWorkspacePlan.removed.map(({ recorded }) => (0, dev_team_1.globalWorkspaceFolder)(recorded.slug)),
174
+ ...appPlan.removed.map(({ recorded }) => recorded.folder),
175
+ ];
176
+ return { outcomes, removedFolders };
177
+ }
178
+ /** Prints the per-workspace result and reports whether anything failed. */
179
+ function reportOutcomes({ outcomes, removedFolders }) {
121
180
  cli_1.default.log('');
122
181
  for (const outcome of outcomes) {
123
182
  if (outcome.error) {
@@ -127,8 +186,8 @@ function reportOutcomes(outcomes, removed) {
127
186
  cli_1.default.log(color_1.default.green(`✅ ${outcome.folder} (${outcome.action})`));
128
187
  }
129
188
  }
130
- for (const { app } of removed) {
131
- cli_1.default.log(color_1.default.yellow(`⚠️ ${app.folder} is no longer in the team — the folder was kept, remove it yourself`));
189
+ for (const folder of removedFolders) {
190
+ cli_1.default.log(color_1.default.yellow(`⚠️ ${folder} is no longer in the team — the folder was kept, remove it yourself`));
132
191
  }
133
192
  return outcomes.some(({ error }) => error !== undefined);
134
193
  }
@@ -1956,7 +1956,7 @@
1956
1956
  "required": false
1957
1957
  }
1958
1958
  },
1959
- "description": "Clone every App of a development team into one folder — an App folder each, holding the App's development workspace",
1959
+ "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/",
1960
1960
  "examples": [
1961
1961
  "$ fcode team:clone",
1962
1962
  "$ fcode team:clone 0f9c1a2b-3d4e-5f60-8a1b-2c3d4e5f6071"
@@ -1988,7 +1988,7 @@
1988
1988
  "team:pull": {
1989
1989
  "aliases": [],
1990
1990
  "args": {},
1991
- "description": "Pull every App of the development team folder: clones Apps added since the last run and pulls each App's workspace",
1991
+ "description": "Pull the development team folder: clones Apps and global workspaces added since the last run and pulls every workspace",
1992
1992
  "examples": [
1993
1993
  "$ fcode team:pull"
1994
1994
  ],
@@ -2012,7 +2012,7 @@
2012
2012
  "team:status": {
2013
2013
  "aliases": [],
2014
2014
  "args": {},
2015
- "description": "Show what differs from the cloud in every App workspace of the development team folder",
2015
+ "description": "Show what differs from the cloud in every workspace of the development team folder",
2016
2016
  "examples": [
2017
2017
  "$ fcode team:status"
2018
2018
  ],
@@ -2483,5 +2483,5 @@
2483
2483
  ]
2484
2484
  }
2485
2485
  },
2486
- "version": "3.0.0"
2486
+ "version": "3.1.0"
2487
2487
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@factorialco/fcode-cli",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "packageManager": "pnpm@11.7.0",
5
5
  "description": "Factorial Code Command Line Interface",
6
6
  "homepage": "https://code.factorialhr.com/docs/cli/",