@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
|
@@ -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(
|
|
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
|
-
|
|
71
|
-
|
|
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,
|
|
186
|
+
response = await (0, node_fetch_1.default)(url, await init());
|
|
77
187
|
}
|
|
78
188
|
}
|
|
79
189
|
if (!response.ok) {
|
|
80
|
-
|
|
190
|
+
const { detail, code } = await this.errorDetail(response);
|
|
191
|
+
throw new DashboardApiError(response.status, path, detail, code);
|
|
81
192
|
}
|
|
82
|
-
return
|
|
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 {
|
|
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 {
|
|
3
|
-
|
|
4
|
-
|
|
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:
|
|
13
|
+
toClone: Remote[];
|
|
7
14
|
/** In the team and already cloned — refreshed and pulled. */
|
|
8
|
-
toRefresh:
|
|
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
|
-
|
|
19
|
+
recorded: Recorded;
|
|
13
20
|
}>;
|
|
14
21
|
}
|
|
15
|
-
export declare function
|
|
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
|
|
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
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
|
35
|
-
/** Prints the per-
|
|
36
|
-
export declare function reportOutcomes(outcomes
|
|
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.
|
|
3
|
+
exports.fetchTeamRemote = fetchTeamRemote;
|
|
4
|
+
exports.planSync = planSync;
|
|
4
5
|
exports.assignFolder = assignFolder;
|
|
5
|
-
exports.
|
|
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
|
|
19
|
-
const
|
|
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:
|
|
22
|
-
toRefresh:
|
|
23
|
-
removed: Object.entries(
|
|
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,
|
|
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
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
*
|
|
57
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 —
|
|
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
|
-
|
|
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
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
|
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(
|
|
101
|
-
outcomes.push({ name: app.name, folder, action: 'cloned' });
|
|
130
|
+
await cloneApp(context, app, folder, warnings);
|
|
102
131
|
}
|
|
103
132
|
catch (error) {
|
|
104
|
-
|
|
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(
|
|
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
|
-
|
|
145
|
+
outcome.error = error;
|
|
115
146
|
}
|
|
147
|
+
outcomes.push(outcome);
|
|
116
148
|
}
|
|
117
149
|
return outcomes;
|
|
118
150
|
}
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
131
|
-
cli_1.default.log(color_1.default.yellow(`⚠️ ${
|
|
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
|
}
|
package/oclif.manifest.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
},
|