@factorialco/fcode-cli 3.1.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,43 @@ 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`);
47
123
  }
48
124
  /** The global workspaces owned by a development team. */
49
125
  async listTeamGlobalWorkspaces(teamId) {
@@ -76,32 +152,62 @@ class DashboardClient {
76
152
  return items;
77
153
  }
78
154
  async get(path, query) {
79
- const url = `${this.baseUrl}${path}?${new URLSearchParams(query).toString()}`;
80
- 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());
81
182
  // The token's group membership is snapshotted at issuance (FCT-61231), so a team joined after
82
183
  // login reads as a denial. Re-mint once and retry before believing it.
83
184
  if (response.status === 401 || response.status === 403) {
84
185
  if (await auth_service_1.default.refreshSession(this.cloudUrl)) {
85
- response = await (0, node_fetch_1.default)(url, { headers: await this.headers() });
186
+ response = await (0, node_fetch_1.default)(url, await init());
86
187
  }
87
188
  }
88
189
  if (!response.ok) {
89
- 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);
90
192
  }
91
- return (await response.json());
193
+ return response;
92
194
  }
93
- async headers() {
195
+ async headers(accept = 'application/json', withBody = false) {
94
196
  const { token } = await this.globalConfig.getAuthInfo(this.cloudUrl);
95
- 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
+ };
96
202
  }
97
203
  async errorDetail(response) {
98
204
  try {
99
205
  const body = await response.text();
100
206
  const parsed = JSON.parse(body);
101
- return parsed.detail ?? parsed.message ?? body;
207
+ return { detail: parsed.detail ?? parsed.message ?? body, code: parsed.code };
102
208
  }
103
209
  catch {
104
- return 'no response body';
210
+ return { detail: 'no response body' };
105
211
  }
106
212
  }
107
213
  }
@@ -1,5 +1,6 @@
1
1
  import DashboardClient, { DevelopmentTeam, DevelopmentTeamApp, DevelopmentTeamGlobalWorkspace } from './dashboard-client';
2
2
  import { DevTeamMetadata } from '../config/dev-team';
3
+ import { AppMetadataSource } from './app-metadata-files';
3
4
  /** What the dashboard currently says the team holds. */
4
5
  export interface DevTeamRemote {
5
6
  apps: DevelopmentTeamApp[];
@@ -33,6 +34,8 @@ export interface TeamSyncOutcome {
33
34
  folder: string;
34
35
  action: 'cloned' | 'pulled';
35
36
  error?: Error;
37
+ /** Metadata files kept or not refreshed; reported, never a failure. */
38
+ warnings?: string[];
36
39
  }
37
40
  export interface TeamSyncResult {
38
41
  outcomes: TeamSyncOutcome[];
@@ -45,7 +48,7 @@ export interface TeamSyncResult {
45
48
  * that lands. A failing workspace is reported and the run carries on: with a dozen Apps, one bad
46
49
  * workspace must not cost the other eleven.
47
50
  */
48
- export declare function syncTeam(rootDir: string, metadata: DevTeamMetadata, remote: DevTeamRemote): Promise<TeamSyncResult>;
51
+ export declare function syncTeam(rootDir: string, metadata: DevTeamMetadata, remote: DevTeamRemote, source: AppMetadataSource): Promise<TeamSyncResult>;
49
52
  /** Prints the per-workspace result and reports whether anything failed. */
50
53
  export declare function reportOutcomes({ outcomes, removedFolders }: TeamSyncResult): boolean;
51
54
  /**
@@ -11,6 +11,7 @@ const path = require("node:path");
11
11
  const color_1 = require("@oclif/color");
12
12
  const pull_1 = require("../commands/pull");
13
13
  const dev_team_1 = require("../config/dev-team");
14
+ const app_metadata_files_1 = require("./app-metadata-files");
14
15
  const skills_installer_1 = require("./skills-installer");
15
16
  const dev_slug_1 = require("../utils/dev-slug");
16
17
  const workspace_cloner_1 = require("./workspace-cloner");
@@ -61,34 +62,39 @@ function writeSkillsGitIgnore(dir) {
61
62
  fs.outputFileSync(gitIgnorePath, ['# Agent skills are installed once at the development-team root and linked here.', '**/.claude/skills', ''].join('\n'));
62
63
  }
63
64
  /**
64
- * Clones one App. The metadata entry is recorded as soon as the directory exists, before the
65
- * pull that may fail: a half-cloned App has to be recognised as cloned next time, or the retry
66
- * 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.
67
67
  */
68
- async function cloneApp(rootDir, app, folder, metadata) {
68
+ async function cloneApp(context, app, folder, warnings) {
69
+ const { rootDir, metadata, source } = context;
69
70
  const slug = (0, dev_slug_1.devSlug)(app.id);
70
71
  const appDir = path.join(rootDir, folder);
71
72
  const workspaceDir = path.join(appDir, dev_team_1.APP_WORKSPACE_DIR);
72
73
  cli_1.default.log(color_1.default.cyan(`\n📦 ${app.name} → ${folder}/${dev_team_1.APP_WORKSPACE_DIR} (${slug})`));
73
74
  await (0, workspace_cloner_1.scaffoldWorkspace)(workspaceDir, { url: metadata.cloudUrl, slug });
74
- 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;
75
77
  (0, dev_team_1.writeAppSettings)(appDir, { id: app.id, name: app.name, description: app.description });
78
+ warnings.push(...(await (0, app_metadata_files_1.syncAppMetadata)(source, appDir, app, recorded)).warnings);
76
79
  writeSkillsGitIgnore(appDir);
77
80
  linkSkills(rootDir, appDir, workspaceDir);
78
81
  await (0, workspace_cloner_1.runInWorkspace)(workspaceDir, () => pull_1.default.run([]));
79
82
  }
80
- async function refreshApp(rootDir, cloudUrl, app, recorded) {
83
+ async function refreshApp(context, app, recorded, warnings) {
84
+ const { rootDir, metadata, source } = context;
81
85
  const appDir = path.join(rootDir, recorded.folder);
82
86
  const workspaceDir = path.join(appDir, dev_team_1.APP_WORKSPACE_DIR);
83
87
  cli_1.default.log(color_1.default.cyan(`\n📦 ${app.name} → ${recorded.folder}/${dev_team_1.APP_WORKSPACE_DIR}`));
84
88
  // A recorded App whose workspace is gone — a failed first clone, or a directory the user
85
89
  // removed — is laid out again rather than failing every pull from here on.
86
90
  if (!fs.pathExistsSync(path.join(workspaceDir, '.fcode'))) {
87
- 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 });
88
92
  }
89
93
  // Name and description live in the dashboard; the local copy is a mirror refreshed on every
90
- // 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`.
91
96
  (0, dev_team_1.writeAppSettings)(appDir, { id: app.id, name: app.name, description: app.description });
97
+ warnings.push(...(await (0, app_metadata_files_1.syncAppMetadata)(source, appDir, app, recorded)).warnings);
92
98
  writeSkillsGitIgnore(appDir);
93
99
  recorded.name = app.name;
94
100
  recorded.description = app.description;
@@ -113,27 +119,32 @@ async function syncGlobalWorkspace(rootDir, workspace, metadata) {
113
119
  (0, skills_installer_1.linkSkillsInto)(workspaceDir, rootDir);
114
120
  await (0, workspace_cloner_1.runInWorkspace)(workspaceDir, () => pull_1.default.run([]));
115
121
  }
116
- async function syncApps(rootDir, metadata, plan) {
122
+ async function syncApps(rootDir, metadata, plan, source) {
117
123
  const outcomes = [];
124
+ const context = { rootDir, metadata, source };
118
125
  for (const app of plan.toClone) {
119
126
  const folder = assignFolder(app, metadata);
127
+ const warnings = [];
128
+ const outcome = { name: app.name, folder, action: 'cloned', warnings };
120
129
  try {
121
- await cloneApp(rootDir, app, folder, metadata);
122
- outcomes.push({ name: app.name, folder, action: 'cloned' });
130
+ await cloneApp(context, app, folder, warnings);
123
131
  }
124
132
  catch (error) {
125
- outcomes.push({ name: app.name, folder, action: 'cloned', error: error });
133
+ outcome.error = error;
126
134
  }
135
+ outcomes.push(outcome);
127
136
  }
128
137
  for (const app of plan.toRefresh) {
129
138
  const recorded = metadata.apps[app.id];
139
+ const warnings = [];
140
+ const outcome = { name: app.name, folder: recorded.folder, action: 'pulled', warnings };
130
141
  try {
131
- await refreshApp(rootDir, metadata.cloudUrl, app, recorded);
132
- outcomes.push({ name: app.name, folder: recorded.folder, action: 'pulled' });
142
+ await refreshApp(context, app, recorded, warnings);
133
143
  }
134
144
  catch (error) {
135
- outcomes.push({ name: app.name, folder: recorded.folder, action: 'pulled', error: error });
145
+ outcome.error = error;
136
146
  }
147
+ outcomes.push(outcome);
137
148
  }
138
149
  return outcomes;
139
150
  }
@@ -162,12 +173,12 @@ async function syncGlobalWorkspaces(rootDir, metadata, plan) {
162
173
  * that lands. A failing workspace is reported and the run carries on: with a dozen Apps, one bad
163
174
  * workspace must not cost the other eleven.
164
175
  */
165
- async function syncTeam(rootDir, metadata, remote) {
176
+ async function syncTeam(rootDir, metadata, remote, source) {
166
177
  const globalWorkspacePlan = planSync(metadata.globalWorkspaces, remote.globalWorkspaces);
167
178
  const appPlan = planSync(metadata.apps, remote.apps);
168
179
  const outcomes = [
169
180
  ...(await syncGlobalWorkspaces(rootDir, metadata, globalWorkspacePlan)),
170
- ...(await syncApps(rootDir, metadata, appPlan)),
181
+ ...(await syncApps(rootDir, metadata, appPlan, source)),
171
182
  ];
172
183
  const removedFolders = [
173
184
  ...globalWorkspacePlan.removed.map(({ recorded }) => (0, dev_team_1.globalWorkspaceFolder)(recorded.slug)),
@@ -185,6 +196,9 @@ function reportOutcomes({ outcomes, removedFolders }) {
185
196
  else {
186
197
  cli_1.default.log(color_1.default.green(`✅ ${outcome.folder} (${outcome.action})`));
187
198
  }
199
+ for (const warning of outcome.warnings ?? []) {
200
+ cli_1.default.log(color_1.default.yellow(` ⚠️ ${warning}`));
201
+ }
188
202
  }
189
203
  for (const folder of removedFolders) {
190
204
  cli_1.default.log(color_1.default.yellow(`⚠️ ${folder} is no longer in the team — the folder was kept, remove it yourself`));
@@ -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": {
@@ -2483,5 +2516,5 @@
2483
2516
  ]
2484
2517
  }
2485
2518
  },
2486
- "version": "3.1.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.1.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
  },