@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.
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DashboardApiError = void 0;
3
4
  exports.dashboardApiUrl = dashboardApiUrl;
4
5
  const node_fetch_1 = require("node-fetch");
5
6
  const auth_service_1 = require("./auth-service");
@@ -12,6 +13,45 @@ const global_1 = require("../config/global");
12
13
  */
13
14
  /** Max the dashboard's list endpoints accept (`listQuerySchema`), so a team is one round trip. */
14
15
  const PAGE_SIZE = 200;
16
+ /** A non-2xx answer from the dashboard, carrying the RFC 9457 problem details it sent. */
17
+ class DashboardApiError extends Error {
18
+ constructor(status, path, detail, code) {
19
+ super(`Dashboard API ${status} on ${path}: ${detail}`);
20
+ this.status = status;
21
+ this.path = path;
22
+ this.detail = detail;
23
+ this.code = code;
24
+ this.name = 'DashboardApiError';
25
+ }
26
+ }
27
+ exports.DashboardApiError = DashboardApiError;
28
+ const appReadmePath = (id) => `/apps/${id}/readme`;
29
+ function mapApp({ id, name, description, publication_state, logo_content_type }) {
30
+ return { id, name, description, publicationState: publication_state, logoContentType: logo_content_type ?? null };
31
+ }
32
+ function mapMarketplaceMetadata(raw) {
33
+ return {
34
+ fullDescription: raw.full_description,
35
+ tagline: raw.tagline,
36
+ datoCategoryId: raw.dato_category_id,
37
+ supportLink: raw.support_link,
38
+ helpLink: raw.help_link,
39
+ datocmsId: raw.datocms_id,
40
+ };
41
+ }
42
+ function mapRelease(raw) {
43
+ return {
44
+ id: raw.id,
45
+ version: raw.version,
46
+ comments: raw.comments,
47
+ status: raw.status,
48
+ error: raw.error,
49
+ requestedBy: raw.requested_by,
50
+ deployedAt: raw.deployed_at,
51
+ stableAt: raw.stable_at,
52
+ createdAt: raw.created_at,
53
+ };
54
+ }
15
55
  /**
16
56
  * `https://code.factorialhr.com/platform` → `https://code.factorialhr.com/dashboard/api/v1`.
17
57
  * The dashboard is served under `/dashboard` on the same origin as the platform; environments
@@ -43,7 +83,52 @@ class DashboardClient {
43
83
  sort_by: 'name',
44
84
  sort_dir: 'asc',
45
85
  });
46
- return items.map(({ id, name, description }) => ({ id, name, description }));
86
+ return items.map(mapApp);
87
+ }
88
+ async getApp(appId) {
89
+ return mapApp(await this.request('GET', `/apps/${appId}`));
90
+ }
91
+ async getAppReadme(appId) {
92
+ const { readme } = await this.request('GET', appReadmePath(appId));
93
+ return { readme: readme ?? null };
94
+ }
95
+ async putAppReadme(appId, readme) {
96
+ const response = await this.request('PUT', appReadmePath(appId), { body: { readme } });
97
+ return { readme: response.readme ?? null };
98
+ }
99
+ async getAppMarketplace(appId) {
100
+ const { metadata } = await this.request('GET', `/apps/${appId}/marketplace`);
101
+ return { metadata: metadata ? mapMarketplaceMetadata(metadata) : null };
102
+ }
103
+ /** Full replace: every field travels, nulls included, since an absent key clears on the dashboard. */
104
+ async putAppMarketplace(appId, input) {
105
+ const raw = await this.request('PUT', `/apps/${appId}/marketplace`, {
106
+ body: {
107
+ full_description: input.fullDescription,
108
+ tagline: input.tagline,
109
+ dato_category_id: input.datoCategoryId,
110
+ support_link: input.supportLink,
111
+ help_link: input.helpLink,
112
+ },
113
+ });
114
+ return mapMarketplaceMetadata(raw);
115
+ }
116
+ async listAppReleases(appId) {
117
+ const items = await this.getAllPages(`/apps/${appId}/releases`);
118
+ return items.map(mapRelease);
119
+ }
120
+ /** The App's logo bytes, or null when it has none. */
121
+ async getAppLogo(appId) {
122
+ return this.getBinary(`/apps/${appId}/logo`);
123
+ }
124
+ /** The global workspaces owned by a development team. */
125
+ async listTeamGlobalWorkspaces(teamId) {
126
+ const items = await this.getAllPages('/global-workspaces', {
127
+ owner_development_team_id: teamId,
128
+ sort_by: 'slug',
129
+ sort_dir: 'asc',
130
+ });
131
+ return items.map(({ id, name, slug, description }) => ({ id, name, slug, description }));
47
132
  }
48
133
  async getAllPages(path, query = {}) {
49
134
  const items = [];
@@ -67,32 +152,62 @@ class DashboardClient {
67
152
  return items;
68
153
  }
69
154
  async get(path, query) {
70
- const url = `${this.baseUrl}${path}?${new URLSearchParams(query).toString()}`;
71
- let response = await (0, node_fetch_1.default)(url, { headers: await this.headers() });
155
+ return this.request('GET', path, { query });
156
+ }
157
+ async request(method, path, options = {}) {
158
+ const response = await this.send(method, path, options);
159
+ return (await response.json());
160
+ }
161
+ async getBinary(path) {
162
+ try {
163
+ const response = await this.send('GET', path, { accept: 'image/*' });
164
+ return { buffer: await response.buffer(), contentType: response.headers.get('content-type') ?? '' };
165
+ }
166
+ catch (error) {
167
+ if (error instanceof DashboardApiError && error.status === 404) {
168
+ return null;
169
+ }
170
+ throw error;
171
+ }
172
+ }
173
+ async send(method, path, { query, body, accept }) {
174
+ const search = query ? `?${new URLSearchParams(query).toString()}` : '';
175
+ const url = `${this.baseUrl}${path}${search}`;
176
+ const init = async () => ({
177
+ method,
178
+ headers: await this.headers(accept, body !== undefined),
179
+ body: body === undefined ? undefined : JSON.stringify(body),
180
+ });
181
+ let response = await (0, node_fetch_1.default)(url, await init());
72
182
  // The token's group membership is snapshotted at issuance (FCT-61231), so a team joined after
73
183
  // login reads as a denial. Re-mint once and retry before believing it.
74
184
  if (response.status === 401 || response.status === 403) {
75
185
  if (await auth_service_1.default.refreshSession(this.cloudUrl)) {
76
- response = await (0, node_fetch_1.default)(url, { headers: await this.headers() });
186
+ response = await (0, node_fetch_1.default)(url, await init());
77
187
  }
78
188
  }
79
189
  if (!response.ok) {
80
- throw new Error(`Dashboard API ${response.status} on ${path}: ${await this.errorDetail(response)}`);
190
+ const { detail, code } = await this.errorDetail(response);
191
+ throw new DashboardApiError(response.status, path, detail, code);
81
192
  }
82
- return (await response.json());
193
+ return response;
83
194
  }
84
- async headers() {
195
+ async headers(accept = 'application/json', withBody = false) {
85
196
  const { token } = await this.globalConfig.getAuthInfo(this.cloudUrl);
86
- return { accept: 'application/json', authorization: `Bearer ${token.access_token}` };
197
+ return {
198
+ accept,
199
+ authorization: `Bearer ${token.access_token}`,
200
+ ...(withBody ? { 'content-type': 'application/json' } : {}),
201
+ };
87
202
  }
88
203
  async errorDetail(response) {
89
204
  try {
90
205
  const body = await response.text();
91
206
  const parsed = JSON.parse(body);
92
- return parsed.detail ?? parsed.message ?? body;
207
+ return { detail: parsed.detail ?? parsed.message ?? body, code: parsed.code };
93
208
  }
94
209
  catch {
95
- return 'no response body';
210
+ return { detail: 'no response body' };
96
211
  }
97
212
  }
98
213
  }
@@ -1,18 +1,27 @@
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
+ import { AppMetadataSource } from './app-metadata-files';
4
+ /** What the dashboard currently says the team holds. */
5
+ export interface DevTeamRemote {
6
+ apps: DevelopmentTeamApp[];
7
+ globalWorkspaces: DevelopmentTeamGlobalWorkspace[];
8
+ }
9
+ export declare function fetchTeamRemote(client: DashboardClient, teamId: string): Promise<DevTeamRemote>;
10
+ /** What a team folder has to do to make one kind of entry match the dashboard. */
11
+ export interface SyncPlan<Remote, Recorded> {
5
12
  /** In the team, with no folder yet — cloned. */
6
- toClone: DevelopmentTeamApp[];
13
+ toClone: Remote[];
7
14
  /** In the team and already cloned — refreshed and pulled. */
8
- toRefresh: DevelopmentTeamApp[];
15
+ toRefresh: Remote[];
9
16
  /** Recorded locally but no longer in the team — reported, never deleted. */
10
17
  removed: Array<{
11
18
  id: string;
12
- app: DevTeamApp;
19
+ recorded: Recorded;
13
20
  }>;
14
21
  }
15
- export declare function planAppSync(metadata: DevTeamMetadata, apps: DevelopmentTeamApp[]): AppSyncPlan;
22
+ export declare function planSync<Remote extends {
23
+ id: string;
24
+ }, Recorded>(recorded: Record<string, Recorded>, remote: Remote[]): SyncPlan<Remote, Recorded>;
16
25
  /**
17
26
  * The folder an App gets inside the team root. Names are free text and two Apps may well slugify
18
27
  * the same, so the first claimant keeps the plain slug and the next ones are suffixed. Only ever
@@ -20,20 +29,28 @@ export declare function planAppSync(metadata: DevTeamMetadata, apps: Development
20
29
  * a renamed App keeps the folder it was cloned into.
21
30
  */
22
31
  export declare function assignFolder(app: DevelopmentTeamApp, metadata: DevTeamMetadata): string;
23
- export interface AppSyncOutcome {
32
+ export interface TeamSyncOutcome {
24
33
  name: string;
25
34
  folder: string;
26
35
  action: 'cloned' | 'pulled';
27
36
  error?: Error;
37
+ /** Metadata files kept or not refreshed; reported, never a failure. */
38
+ warnings?: string[];
39
+ }
40
+ export interface TeamSyncResult {
41
+ outcomes: TeamSyncOutcome[];
42
+ /** Folders recorded locally whose App or global workspace has left the team. */
43
+ removedFolders: string[];
28
44
  }
29
45
  /**
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.
46
+ * Brings the team folder in line with the dashboard — global workspaces first, since the Apps
47
+ * inherit from them, then the Apps — one workspace at a time, recording the metadata of each one
48
+ * that lands. A failing workspace is reported and the run carries on: with a dozen Apps, one bad
49
+ * workspace must not cost the other eleven.
33
50
  */
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;
51
+ export declare function syncTeam(rootDir: string, metadata: DevTeamMetadata, remote: DevTeamRemote, source: AppMetadataSource): Promise<TeamSyncResult>;
52
+ /** Prints the per-workspace result and reports whether anything failed. */
53
+ export declare function reportOutcomes({ outcomes, removedFolders }: TeamSyncResult): boolean;
37
54
  /**
38
55
  * The team to operate on: the one named, the only one the session belongs to, or — when there are
39
56
  * 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");
@@ -10,19 +11,24 @@ const path = require("node:path");
10
11
  const color_1 = require("@oclif/color");
11
12
  const pull_1 = require("../commands/pull");
12
13
  const dev_team_1 = require("../config/dev-team");
14
+ const app_metadata_files_1 = require("./app-metadata-files");
13
15
  const skills_installer_1 = require("./skills-installer");
14
16
  const dev_slug_1 = require("../utils/dev-slug");
15
17
  const workspace_cloner_1 = require("./workspace-cloner");
16
18
  const slugify_1 = require("../utils/slugify");
17
19
  const cli_1 = require("../utils/cli");
18
- function planAppSync(metadata, apps) {
19
- const remoteIds = new Set(apps.map(({ id }) => id));
20
+ async function fetchTeamRemote(client, teamId) {
21
+ const [apps, globalWorkspaces] = await Promise.all([client.listTeamApps(teamId), client.listTeamGlobalWorkspaces(teamId)]);
22
+ return { apps, globalWorkspaces };
23
+ }
24
+ function planSync(recorded, remote) {
25
+ const remoteIds = new Set(remote.map(({ id }) => id));
20
26
  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)
27
+ toClone: remote.filter(({ id }) => recorded[id] === undefined),
28
+ toRefresh: remote.filter(({ id }) => recorded[id] !== undefined),
29
+ removed: Object.entries(recorded)
24
30
  .filter(([id]) => !remoteIds.has(id))
25
- .map(([id, app]) => ({ id, app })),
31
+ .map(([id, entry]) => ({ id, recorded: entry })),
26
32
  };
27
33
  }
28
34
  /**
@@ -44,80 +50,144 @@ function linkSkills(rootDir, appDir, workspaceDir) {
44
50
  (0, skills_installer_1.linkSkillsInto)(appDir, rootDir);
45
51
  (0, skills_installer_1.linkSkillsInto)(workspaceDir, rootDir);
46
52
  }
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');
53
+ /**
54
+ * A committed .gitignore keeping the skill links out of the repository: one in each App folder,
55
+ * since each is meant to become its own repository, and one over the global workspaces.
56
+ */
57
+ function writeSkillsGitIgnore(dir) {
58
+ const gitIgnorePath = path.join(dir, '.gitignore');
50
59
  if (fs.pathExistsSync(gitIgnorePath)) {
51
60
  return;
52
61
  }
53
62
  fs.outputFileSync(gitIgnorePath, ['# Agent skills are installed once at the development-team root and linked here.', '**/.claude/skills', ''].join('\n'));
54
63
  }
55
64
  /**
56
- * Clones one App. The metadata entry is recorded as soon as the directory exists, before the
57
- * pull that may fail: a half-cloned App has to be recognised as cloned next time, or the retry
58
- * would try to create it again and stop at "already cloned".
65
+ * Metadata warnings go into the caller's sink rather than the return value: the workspace pull
66
+ * that follows may throw, and what was said about the files before that still holds.
59
67
  */
60
- async function cloneApp(rootDir, app, folder, metadata) {
68
+ async function cloneApp(context, app, folder, warnings) {
69
+ const { rootDir, metadata, source } = context;
61
70
  const slug = (0, dev_slug_1.devSlug)(app.id);
62
71
  const appDir = path.join(rootDir, folder);
63
72
  const workspaceDir = path.join(appDir, dev_team_1.APP_WORKSPACE_DIR);
64
73
  cli_1.default.log(color_1.default.cyan(`\n📦 ${app.name} → ${folder}/${dev_team_1.APP_WORKSPACE_DIR} (${slug})`));
65
74
  await (0, workspace_cloner_1.scaffoldWorkspace)(workspaceDir, { url: metadata.cloudUrl, slug });
66
- metadata.apps[app.id] = { name: app.name, description: app.description, folder, devWorkspaceSlug: slug };
75
+ const recorded = { name: app.name, description: app.description, folder, devWorkspaceSlug: slug };
76
+ metadata.apps[app.id] = recorded;
67
77
  (0, dev_team_1.writeAppSettings)(appDir, { id: app.id, name: app.name, description: app.description });
68
- writeAppGitIgnore(appDir);
78
+ warnings.push(...(await (0, app_metadata_files_1.syncAppMetadata)(source, appDir, app, recorded)).warnings);
79
+ writeSkillsGitIgnore(appDir);
69
80
  linkSkills(rootDir, appDir, workspaceDir);
70
81
  await (0, workspace_cloner_1.runInWorkspace)(workspaceDir, () => pull_1.default.run([]));
71
82
  }
72
- async function refreshApp(rootDir, cloudUrl, app, recorded) {
83
+ async function refreshApp(context, app, recorded, warnings) {
84
+ const { rootDir, metadata, source } = context;
73
85
  const appDir = path.join(rootDir, recorded.folder);
74
86
  const workspaceDir = path.join(appDir, dev_team_1.APP_WORKSPACE_DIR);
75
87
  cli_1.default.log(color_1.default.cyan(`\n📦 ${app.name} → ${recorded.folder}/${dev_team_1.APP_WORKSPACE_DIR}`));
76
88
  // A recorded App whose workspace is gone — a failed first clone, or a directory the user
77
89
  // removed — is laid out again rather than failing every pull from here on.
78
90
  if (!fs.pathExistsSync(path.join(workspaceDir, '.fcode'))) {
79
- await (0, workspace_cloner_1.scaffoldWorkspace)(workspaceDir, { url: cloudUrl, slug: recorded.devWorkspaceSlug });
91
+ await (0, workspace_cloner_1.scaffoldWorkspace)(workspaceDir, { url: metadata.cloudUrl, slug: recorded.devWorkspaceSlug });
80
92
  }
81
93
  // Name and description live in the dashboard; the local copy is a mirror refreshed on every
82
- // pull. There is deliberately no team:push — pushing happens inside a workspace.
94
+ // pull. There is deliberately no team:push — code is pushed inside a workspace, and the App's
95
+ // README.md / MARKETPLACE.md with `fcode app:push`.
83
96
  (0, dev_team_1.writeAppSettings)(appDir, { id: app.id, name: app.name, description: app.description });
84
- writeAppGitIgnore(appDir);
97
+ warnings.push(...(await (0, app_metadata_files_1.syncAppMetadata)(source, appDir, app, recorded)).warnings);
98
+ writeSkillsGitIgnore(appDir);
85
99
  recorded.name = app.name;
86
100
  recorded.description = app.description;
87
101
  linkSkills(rootDir, appDir, workspaceDir);
88
102
  await (0, workspace_cloner_1.runInWorkspace)(workspaceDir, () => pull_1.default.run([]));
89
103
  }
90
104
  /**
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.
105
+ * Clones or refreshes one global workspace. Its slug is the platform team slug — fixed, unique and
106
+ * filesystem-safe — so the checkout goes straight to `global-workspaces/{slug}` with no folder to
107
+ * assign and no App-level settings to mirror. Recorded before the pull for the same reason as an
108
+ * App: a half-cloned checkout must read as cloned on the retry.
94
109
  */
95
- async function syncApps(rootDir, metadata, plan) {
110
+ async function syncGlobalWorkspace(rootDir, workspace, metadata) {
111
+ const folder = (0, dev_team_1.globalWorkspaceFolder)(workspace.slug);
112
+ const workspaceDir = path.join(rootDir, folder);
113
+ cli_1.default.log(color_1.default.cyan(`\n🌐 ${workspace.name} → ${folder}`));
114
+ if (!fs.pathExistsSync(path.join(workspaceDir, '.fcode'))) {
115
+ await (0, workspace_cloner_1.scaffoldWorkspace)(workspaceDir, { url: metadata.cloudUrl, slug: workspace.slug });
116
+ }
117
+ metadata.globalWorkspaces[workspace.id] = { name: workspace.name, description: workspace.description, slug: workspace.slug };
118
+ writeSkillsGitIgnore(path.join(rootDir, dev_team_1.GLOBAL_WORKSPACES_DIR));
119
+ (0, skills_installer_1.linkSkillsInto)(workspaceDir, rootDir);
120
+ await (0, workspace_cloner_1.runInWorkspace)(workspaceDir, () => pull_1.default.run([]));
121
+ }
122
+ async function syncApps(rootDir, metadata, plan, source) {
96
123
  const outcomes = [];
124
+ const context = { rootDir, metadata, source };
97
125
  for (const app of plan.toClone) {
98
126
  const folder = assignFolder(app, metadata);
127
+ const warnings = [];
128
+ const outcome = { name: app.name, folder, action: 'cloned', warnings };
99
129
  try {
100
- await cloneApp(rootDir, app, folder, metadata);
101
- outcomes.push({ name: app.name, folder, action: 'cloned' });
130
+ await cloneApp(context, app, folder, warnings);
102
131
  }
103
132
  catch (error) {
104
- outcomes.push({ name: app.name, folder, action: 'cloned', error: error });
133
+ outcome.error = error;
105
134
  }
135
+ outcomes.push(outcome);
106
136
  }
107
137
  for (const app of plan.toRefresh) {
108
138
  const recorded = metadata.apps[app.id];
139
+ const warnings = [];
140
+ const outcome = { name: app.name, folder: recorded.folder, action: 'pulled', warnings };
109
141
  try {
110
- await refreshApp(rootDir, metadata.cloudUrl, app, recorded);
111
- outcomes.push({ name: app.name, folder: recorded.folder, action: 'pulled' });
142
+ await refreshApp(context, app, recorded, warnings);
112
143
  }
113
144
  catch (error) {
114
- outcomes.push({ name: app.name, folder: recorded.folder, action: 'pulled', error: error });
145
+ outcome.error = error;
115
146
  }
147
+ outcomes.push(outcome);
116
148
  }
117
149
  return outcomes;
118
150
  }
119
- /** Prints the per-App result and reports whether anything failed. */
120
- function reportOutcomes(outcomes, removed) {
151
+ async function syncGlobalWorkspaces(rootDir, metadata, plan) {
152
+ const outcomes = [];
153
+ for (const [action, workspaces] of [
154
+ ['cloned', plan.toClone],
155
+ ['pulled', plan.toRefresh],
156
+ ]) {
157
+ for (const workspace of workspaces) {
158
+ const folder = (0, dev_team_1.globalWorkspaceFolder)(workspace.slug);
159
+ try {
160
+ await syncGlobalWorkspace(rootDir, workspace, metadata);
161
+ outcomes.push({ name: workspace.name, folder, action });
162
+ }
163
+ catch (error) {
164
+ outcomes.push({ name: workspace.name, folder, action, error: error });
165
+ }
166
+ }
167
+ }
168
+ return outcomes;
169
+ }
170
+ /**
171
+ * Brings the team folder in line with the dashboard — global workspaces first, since the Apps
172
+ * inherit from them, then the Apps — one workspace at a time, recording the metadata of each one
173
+ * that lands. A failing workspace is reported and the run carries on: with a dozen Apps, one bad
174
+ * workspace must not cost the other eleven.
175
+ */
176
+ async function syncTeam(rootDir, metadata, remote, source) {
177
+ const globalWorkspacePlan = planSync(metadata.globalWorkspaces, remote.globalWorkspaces);
178
+ const appPlan = planSync(metadata.apps, remote.apps);
179
+ const outcomes = [
180
+ ...(await syncGlobalWorkspaces(rootDir, metadata, globalWorkspacePlan)),
181
+ ...(await syncApps(rootDir, metadata, appPlan, source)),
182
+ ];
183
+ const removedFolders = [
184
+ ...globalWorkspacePlan.removed.map(({ recorded }) => (0, dev_team_1.globalWorkspaceFolder)(recorded.slug)),
185
+ ...appPlan.removed.map(({ recorded }) => recorded.folder),
186
+ ];
187
+ return { outcomes, removedFolders };
188
+ }
189
+ /** Prints the per-workspace result and reports whether anything failed. */
190
+ function reportOutcomes({ outcomes, removedFolders }) {
121
191
  cli_1.default.log('');
122
192
  for (const outcome of outcomes) {
123
193
  if (outcome.error) {
@@ -126,9 +196,12 @@ function reportOutcomes(outcomes, removed) {
126
196
  else {
127
197
  cli_1.default.log(color_1.default.green(`✅ ${outcome.folder} (${outcome.action})`));
128
198
  }
199
+ for (const warning of outcome.warnings ?? []) {
200
+ cli_1.default.log(color_1.default.yellow(` ⚠️ ${warning}`));
201
+ }
129
202
  }
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`));
203
+ for (const folder of removedFolders) {
204
+ cli_1.default.log(color_1.default.yellow(`⚠️ ${folder} is no longer in the team — the folder was kept, remove it yourself`));
132
205
  }
133
206
  return outcomes.some(({ error }) => error !== undefined);
134
207
  }
@@ -220,7 +220,7 @@
220
220
  },
221
221
  "prompt": {
222
222
  "char": "x",
223
- "description": "Prompt for email and password (instead of opening browser)",
223
+ "description": "Prompt for email and password (instead of opening browser). Make it the default with 'fcode config:set disableBrowserLogin true' or FCODE_DISABLE_BROWSER_LOGIN=true",
224
224
  "name": "prompt",
225
225
  "allowNo": false,
226
226
  "type": "boolean"
@@ -701,6 +701,39 @@
701
701
  "test.js"
702
702
  ]
703
703
  },
704
+ "app:push": {
705
+ "aliases": [],
706
+ "args": {},
707
+ "description": "Upload the App folder's README.md and MARKETPLACE.md to the dashboard",
708
+ "examples": [
709
+ "$ fcode app:push",
710
+ "$ fcode app:push --force"
711
+ ],
712
+ "flags": {
713
+ "force": {
714
+ "char": "f",
715
+ "description": "Force command",
716
+ "name": "force",
717
+ "allowNo": false,
718
+ "type": "boolean"
719
+ }
720
+ },
721
+ "hasDynamicHelp": false,
722
+ "hiddenAliases": [],
723
+ "id": "app:push",
724
+ "pluginAlias": "@factorialco/fcode-cli",
725
+ "pluginName": "@factorialco/fcode-cli",
726
+ "pluginType": "core",
727
+ "strict": true,
728
+ "enableJsonFlag": false,
729
+ "isESM": false,
730
+ "relativePath": [
731
+ "dist",
732
+ "commands",
733
+ "app",
734
+ "push.js"
735
+ ]
736
+ },
704
737
  "config:get": {
705
738
  "aliases": [],
706
739
  "args": {
@@ -1956,7 +1989,7 @@
1956
1989
  "required": false
1957
1990
  }
1958
1991
  },
1959
- "description": "Clone every App of a development team into one folder — an App folder each, holding the App's development workspace",
1992
+ "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
1993
  "examples": [
1961
1994
  "$ fcode team:clone",
1962
1995
  "$ fcode team:clone 0f9c1a2b-3d4e-5f60-8a1b-2c3d4e5f6071"
@@ -1988,7 +2021,7 @@
1988
2021
  "team:pull": {
1989
2022
  "aliases": [],
1990
2023
  "args": {},
1991
- "description": "Pull every App of the development team folder: clones Apps added since the last run and pulls each App's workspace",
2024
+ "description": "Pull the development team folder: clones Apps and global workspaces added since the last run and pulls every workspace",
1992
2025
  "examples": [
1993
2026
  "$ fcode team:pull"
1994
2027
  ],
@@ -2012,7 +2045,7 @@
2012
2045
  "team:status": {
2013
2046
  "aliases": [],
2014
2047
  "args": {},
2015
- "description": "Show what differs from the cloud in every App workspace of the development team folder",
2048
+ "description": "Show what differs from the cloud in every workspace of the development team folder",
2016
2049
  "examples": [
2017
2050
  "$ fcode team:status"
2018
2051
  ],
@@ -2483,5 +2516,5 @@
2483
2516
  ]
2484
2517
  }
2485
2518
  },
2486
- "version": "3.0.0"
2519
+ "version": "3.2.0"
2487
2520
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@factorialco/fcode-cli",
3
- "version": "3.0.0",
3
+ "version": "3.2.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/",
@@ -127,6 +127,9 @@
127
127
  },
128
128
  "team": {
129
129
  "description": "Work with every App your development team owns, in one folder"
130
+ },
131
+ "app": {
132
+ "description": "Work on the App folder around a workspace: its README, marketplace listing and changelog"
130
133
  }
131
134
  }
132
135
  },