@factorialco/fcode-cli 3.0.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -8
- package/dist/commands/app/push.d.ts +13 -0
- package/dist/commands/app/push.js +147 -0
- package/dist/commands/login.js +2 -1
- package/dist/commands/team/clone.js +10 -11
- package/dist/commands/team/pull.js +6 -6
- package/dist/commands/team/status.d.ts +2 -0
- package/dist/commands/team/status.js +53 -7
- package/dist/config/dev-team.d.ts +31 -0
- package/dist/config/dev-team.js +71 -4
- package/dist/config/settings.d.ts +1 -0
- package/dist/config/settings.js +6 -0
- package/dist/hooks/init/auth.d.ts +19 -0
- package/dist/hooks/init/auth.js +40 -6
- package/dist/services/app-metadata-files.d.ts +98 -0
- package/dist/services/app-metadata-files.js +363 -0
- package/dist/services/dashboard-client.d.ts +71 -0
- package/dist/services/dashboard-client.js +125 -10
- package/dist/services/dev-team-service.d.ts +32 -15
- package/dist/services/dev-team-service.js +108 -35
- package/oclif.manifest.json +38 -5
- package/package.json +4 -1
package/dist/config/settings.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.SETTINGS_DEFINITIONS = exports.SettingKeys = void 0;
|
|
|
4
4
|
exports.getSettingDefault = getSettingDefault;
|
|
5
5
|
var SettingKeys;
|
|
6
6
|
(function (SettingKeys) {
|
|
7
|
+
SettingKeys["DISABLE_BROWSER_LOGIN"] = "disableBrowserLogin";
|
|
7
8
|
SettingKeys["DISABLE_UPDATE_NOTIFIER"] = "disableUpdateNotifier";
|
|
8
9
|
SettingKeys["IGNORE_PROCESS_VERSIONS"] = "ignoreProcessVersions";
|
|
9
10
|
SettingKeys["IGNORE_WORKSPACE_NOT_EMPTY"] = "ignoreWorkspaceNotEmpty";
|
|
@@ -24,6 +25,11 @@ exports.SETTINGS_DEFINITIONS = [
|
|
|
24
25
|
default: 'false',
|
|
25
26
|
description: 'Disable update notifier',
|
|
26
27
|
},
|
|
28
|
+
{
|
|
29
|
+
key: SettingKeys.DISABLE_BROWSER_LOGIN,
|
|
30
|
+
default: 'false',
|
|
31
|
+
description: 'Disable the interactive browser OAuth login; fcode login prompts for email and password instead',
|
|
32
|
+
},
|
|
27
33
|
{
|
|
28
34
|
key: SettingKeys.SKIP_SKILLS_SETUP,
|
|
29
35
|
default: 'false',
|
|
@@ -1,3 +1,22 @@
|
|
|
1
1
|
import { Hook } from '@oclif/core';
|
|
2
|
+
import GlobalConfig from '../../config/global';
|
|
3
|
+
export type LoginMethod = 'accessToken' | 'credentials' | 'browser';
|
|
4
|
+
export interface LoginOptions {
|
|
5
|
+
email?: string;
|
|
6
|
+
password?: string;
|
|
7
|
+
clientId?: string;
|
|
8
|
+
clientSecret?: string;
|
|
9
|
+
accessToken?: string;
|
|
10
|
+
prompt?: boolean;
|
|
11
|
+
browserLoginDisabled?: boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Picks how to log in from what the user passed. The browser OAuth flow is the default, but it is
|
|
15
|
+
* skipped when the user asked to be prompted (`--prompt`), when it is disabled through the
|
|
16
|
+
* `disableBrowserLogin` setting or the `FCODE_DISABLE_BROWSER_LOGIN` env var (headless machines,
|
|
17
|
+
* CI, remote shells), or when credentials that identify another method were given.
|
|
18
|
+
*/
|
|
19
|
+
export declare function resolveLoginMethod(options: LoginOptions): LoginMethod;
|
|
20
|
+
export declare function isBrowserLoginDisabled(globalConfig?: GlobalConfig): Promise<boolean>;
|
|
2
21
|
declare const auth: Hook<'init'>;
|
|
3
22
|
export default auth;
|
package/dist/hooks/init/auth.js
CHANGED
|
@@ -1,27 +1,61 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveLoginMethod = resolveLoginMethod;
|
|
4
|
+
exports.isBrowserLoginDisabled = isBrowserLoginDisabled;
|
|
3
5
|
const dotenv_1 = require("dotenv");
|
|
4
6
|
const auth_service_1 = require("../../services/auth-service");
|
|
5
7
|
const credential_prompter_1 = require("../../services/credential-prompter");
|
|
6
8
|
const oauth_browser_service_1 = require("../../services/oauth-browser-service");
|
|
7
9
|
const global_1 = require("../../config/global");
|
|
10
|
+
const settings_1 = require("../../config/settings");
|
|
8
11
|
const url_utils_1 = require("../../utils/url-utils");
|
|
12
|
+
/**
|
|
13
|
+
* Picks how to log in from what the user passed. The browser OAuth flow is the default, but it is
|
|
14
|
+
* skipped when the user asked to be prompted (`--prompt`), when it is disabled through the
|
|
15
|
+
* `disableBrowserLogin` setting or the `FCODE_DISABLE_BROWSER_LOGIN` env var (headless machines,
|
|
16
|
+
* CI, remote shells), or when credentials that identify another method were given.
|
|
17
|
+
*/
|
|
18
|
+
function resolveLoginMethod(options) {
|
|
19
|
+
if (options.accessToken) {
|
|
20
|
+
return 'accessToken';
|
|
21
|
+
}
|
|
22
|
+
if (options.prompt ||
|
|
23
|
+
options.browserLoginDisabled ||
|
|
24
|
+
options.email ||
|
|
25
|
+
options.password ||
|
|
26
|
+
(options.clientId && options.clientSecret)) {
|
|
27
|
+
return 'credentials';
|
|
28
|
+
}
|
|
29
|
+
return 'browser';
|
|
30
|
+
}
|
|
31
|
+
async function isBrowserLoginDisabled(globalConfig = new global_1.default()) {
|
|
32
|
+
return (process.env.FCODE_DISABLE_BROWSER_LOGIN === 'true' ||
|
|
33
|
+
(await globalConfig.getSetting(settings_1.SettingKeys.DISABLE_BROWSER_LOGIN)) === 'true');
|
|
34
|
+
}
|
|
9
35
|
const auth = async function ({ argv }) {
|
|
10
36
|
const url = argv[0] || global_1.FCODE_CLOUD_URL;
|
|
11
37
|
const authUrl = argv[1] || (0, url_utils_1.buildAuthUrl)(url);
|
|
12
38
|
const isAuthenticated = await auth_service_1.default.isAuthenticated(url);
|
|
13
39
|
if (!isAuthenticated) {
|
|
14
40
|
(0, dotenv_1.config)();
|
|
15
|
-
const accessToken = argv
|
|
16
|
-
const
|
|
17
|
-
|
|
41
|
+
const [, , email, password, clientId, clientSecret, accessToken, prompt] = argv;
|
|
42
|
+
const method = resolveLoginMethod({
|
|
43
|
+
email,
|
|
44
|
+
password,
|
|
45
|
+
clientId,
|
|
46
|
+
clientSecret,
|
|
47
|
+
accessToken,
|
|
48
|
+
prompt: Boolean(prompt),
|
|
49
|
+
browserLoginDisabled: await isBrowserLoginDisabled(),
|
|
50
|
+
});
|
|
51
|
+
if (method === 'accessToken') {
|
|
18
52
|
await auth_service_1.default.login({ url, authUrl, accessToken });
|
|
19
53
|
}
|
|
20
|
-
else if (
|
|
21
|
-
await credential_prompter_1.default.prompt({ url, authUrl, email
|
|
54
|
+
else if (method === 'credentials') {
|
|
55
|
+
await credential_prompter_1.default.prompt({ url, authUrl, email, password, clientId, clientSecret });
|
|
22
56
|
}
|
|
23
57
|
else {
|
|
24
|
-
await (0, oauth_browser_service_1.loginViaBrowser)(url, authUrl,
|
|
58
|
+
await (0, oauth_browser_service_1.loginViaBrowser)(url, authUrl, clientId);
|
|
25
59
|
}
|
|
26
60
|
}
|
|
27
61
|
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { CLIError } from '../core/errors';
|
|
2
|
+
import { DevTeamApp } from '../config/dev-team';
|
|
3
|
+
import { AppLogo, AppMarketplace, AppMarketplaceInput, AppMarketplaceMetadata, AppReadme, AppRelease, DevelopmentTeamApp } from './dashboard-client';
|
|
4
|
+
/**
|
|
5
|
+
* The files an App folder carries next to `settings.json` and the `app/` checkout. README.md and
|
|
6
|
+
* MARKETPLACE.md are the developer's (pulled, kept when edited, uploaded with `fcode app:push`);
|
|
7
|
+
* CHANGELOG.md and the logo are mirrors of the dashboard, rewritten on every pull.
|
|
8
|
+
*/
|
|
9
|
+
export declare const APP_README_FILENAME = "README.md";
|
|
10
|
+
export declare const APP_MARKETPLACE_FILENAME = "MARKETPLACE.md";
|
|
11
|
+
export declare const APP_CHANGELOG_FILENAME = "CHANGELOG.md";
|
|
12
|
+
export declare const APP_MARKETPLACE_DIR = "marketplace";
|
|
13
|
+
export type EditableFile = typeof APP_README_FILENAME | typeof APP_MARKETPLACE_FILENAME;
|
|
14
|
+
export declare class InvalidMarketplaceFile extends CLIError {
|
|
15
|
+
constructor(reason: string);
|
|
16
|
+
}
|
|
17
|
+
export declare function hashText(text: string): string;
|
|
18
|
+
export declare function renderReadme(app: {
|
|
19
|
+
name: string;
|
|
20
|
+
description: string | null;
|
|
21
|
+
}, readme: string | null): string;
|
|
22
|
+
/** What the file means for the cloud: a blank README clears it. */
|
|
23
|
+
export declare function readmeForPush(text: string): string | null;
|
|
24
|
+
export type MarketplaceFrontmatter = Omit<AppMarketplaceInput, 'fullDescription'>;
|
|
25
|
+
export interface ParsedMarketplaceFile {
|
|
26
|
+
frontmatter: MarketplaceFrontmatter;
|
|
27
|
+
body: string;
|
|
28
|
+
}
|
|
29
|
+
export declare function renderMarketplaceFile(app: {
|
|
30
|
+
name: string;
|
|
31
|
+
publicationState: string;
|
|
32
|
+
}, metadata: AppMarketplaceMetadata | null): string;
|
|
33
|
+
export declare function parseMarketplaceFile(rawText: string): ParsedMarketplaceFile;
|
|
34
|
+
/** `'empty'` when nothing was ever written: no description and no field — not an error, just nothing to send. */
|
|
35
|
+
export declare function marketplaceForPush({ frontmatter, body }: ParsedMarketplaceFile): AppMarketplaceInput | 'empty';
|
|
36
|
+
/** Who requested a release stays in the dashboard: the file is meant to be committed. */
|
|
37
|
+
export declare function renderChangelog(app: {
|
|
38
|
+
name: string;
|
|
39
|
+
}, releases: AppRelease[]): string;
|
|
40
|
+
/** Mirrors the dashboard logo; returns a warning instead of writing when the type is not one we know. */
|
|
41
|
+
export declare function writeAppLogo(appDir: string, logo: AppLogo | null): string | undefined;
|
|
42
|
+
export type MetadataSyncState = 'up-to-date' | 'modified' | 'out-of-date' | 'conflict' | 'unsynced';
|
|
43
|
+
/**
|
|
44
|
+
* The part of an editable file the developer owns, in one canonical shape — what the sync hash
|
|
45
|
+
* covers. Comments, YAML quoting, line endings and a BOM are not content, so a listing published
|
|
46
|
+
* from the dashboard or a Windows checkout never reads as an edit. A MARKETPLACE.md whose
|
|
47
|
+
* frontmatter does not parse is compared as raw text: it differs from anything the cloud renders,
|
|
48
|
+
* and `app:push` reports the actual error.
|
|
49
|
+
*/
|
|
50
|
+
export declare function editableContent(file: EditableFile, text: string): string;
|
|
51
|
+
export declare function hashEditable(file: EditableFile, text: string): string;
|
|
52
|
+
/** What a pull writes for one editable file, and whether the cloud actually holds it. */
|
|
53
|
+
export interface EditableRendering {
|
|
54
|
+
file: EditableFile;
|
|
55
|
+
text: string;
|
|
56
|
+
/** A stub the CLI made up because the cloud has nothing: local edits win over its changes. */
|
|
57
|
+
generated: boolean;
|
|
58
|
+
}
|
|
59
|
+
export declare function readmeRendering(app: {
|
|
60
|
+
name: string;
|
|
61
|
+
description: string | null;
|
|
62
|
+
}, cloud: AppReadme): EditableRendering;
|
|
63
|
+
export declare function marketplaceRendering(app: {
|
|
64
|
+
name: string;
|
|
65
|
+
publicationState: string;
|
|
66
|
+
}, cloud: AppMarketplace): EditableRendering;
|
|
67
|
+
/**
|
|
68
|
+
* Same model as the workspace settings sync: the stored hash is the last thing both sides agreed
|
|
69
|
+
* on. With none, a file that already matches the cloud is simply adopted; one that differs was
|
|
70
|
+
* written by hand before any sync (`unsynced`). A stub the cloud never held cannot conflict with
|
|
71
|
+
* a local edit — there is nothing in the cloud to lose.
|
|
72
|
+
*/
|
|
73
|
+
export declare function metadataSyncState(localText: string, storedHash: string | undefined, cloud: EditableRendering): MetadataSyncState;
|
|
74
|
+
/** Writes the file as the cloud renders it and returns the hash to record for it. */
|
|
75
|
+
export declare function writeEditable(appDir: string, rendering: EditableRendering): string;
|
|
76
|
+
export interface AppMetadataSource {
|
|
77
|
+
getAppReadme(appId: string): Promise<AppReadme>;
|
|
78
|
+
getAppMarketplace(appId: string): Promise<AppMarketplace>;
|
|
79
|
+
listAppReleases(appId: string): Promise<AppRelease[]>;
|
|
80
|
+
getAppLogo(appId: string): Promise<AppLogo | null>;
|
|
81
|
+
}
|
|
82
|
+
export interface AppMetadataSyncResult {
|
|
83
|
+
warnings: string[];
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Lays the metadata files out for one App. Never throws: a file the dashboard could not serve is
|
|
87
|
+
* left as it was and reported, so a metadata hiccup cannot fail the clone of the workspace.
|
|
88
|
+
*/
|
|
89
|
+
export declare function syncAppMetadata(source: AppMetadataSource, appDir: string, app: DevelopmentTeamApp, recorded: DevTeamApp): Promise<AppMetadataSyncResult>;
|
|
90
|
+
export interface MetadataFileStatus {
|
|
91
|
+
file: string;
|
|
92
|
+
state: MetadataSyncState | 'missing';
|
|
93
|
+
}
|
|
94
|
+
/** What `team:status` and `app:push` report for the two editable files, given the cloud renderings. */
|
|
95
|
+
export declare function metadataStatus(appDir: string, app: DevelopmentTeamApp, recorded: DevTeamApp, cloud: {
|
|
96
|
+
readme: AppReadme;
|
|
97
|
+
marketplace: AppMarketplace;
|
|
98
|
+
}): MetadataFileStatus[];
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.InvalidMarketplaceFile = exports.APP_MARKETPLACE_DIR = exports.APP_CHANGELOG_FILENAME = exports.APP_MARKETPLACE_FILENAME = exports.APP_README_FILENAME = void 0;
|
|
4
|
+
exports.hashText = hashText;
|
|
5
|
+
exports.renderReadme = renderReadme;
|
|
6
|
+
exports.readmeForPush = readmeForPush;
|
|
7
|
+
exports.renderMarketplaceFile = renderMarketplaceFile;
|
|
8
|
+
exports.parseMarketplaceFile = parseMarketplaceFile;
|
|
9
|
+
exports.marketplaceForPush = marketplaceForPush;
|
|
10
|
+
exports.renderChangelog = renderChangelog;
|
|
11
|
+
exports.writeAppLogo = writeAppLogo;
|
|
12
|
+
exports.editableContent = editableContent;
|
|
13
|
+
exports.hashEditable = hashEditable;
|
|
14
|
+
exports.readmeRendering = readmeRendering;
|
|
15
|
+
exports.marketplaceRendering = marketplaceRendering;
|
|
16
|
+
exports.metadataSyncState = metadataSyncState;
|
|
17
|
+
exports.writeEditable = writeEditable;
|
|
18
|
+
exports.syncAppMetadata = syncAppMetadata;
|
|
19
|
+
exports.metadataStatus = metadataStatus;
|
|
20
|
+
const crypto = require("node:crypto");
|
|
21
|
+
const fs = require("fs-extra");
|
|
22
|
+
const path = require("node:path");
|
|
23
|
+
const yaml_1 = require("yaml");
|
|
24
|
+
const errors_1 = require("../core/errors");
|
|
25
|
+
const file_utils_1 = require("../utils/file-utils");
|
|
26
|
+
/**
|
|
27
|
+
* The files an App folder carries next to `settings.json` and the `app/` checkout. README.md and
|
|
28
|
+
* MARKETPLACE.md are the developer's (pulled, kept when edited, uploaded with `fcode app:push`);
|
|
29
|
+
* CHANGELOG.md and the logo are mirrors of the dashboard, rewritten on every pull.
|
|
30
|
+
*/
|
|
31
|
+
exports.APP_README_FILENAME = 'README.md';
|
|
32
|
+
exports.APP_MARKETPLACE_FILENAME = 'MARKETPLACE.md';
|
|
33
|
+
exports.APP_CHANGELOG_FILENAME = 'CHANGELOG.md';
|
|
34
|
+
exports.APP_MARKETPLACE_DIR = 'marketplace';
|
|
35
|
+
const LOGO_BASENAME = 'logo';
|
|
36
|
+
const LOGO_EXTENSIONS = {
|
|
37
|
+
'image/png': 'png',
|
|
38
|
+
'image/jpeg': 'jpg',
|
|
39
|
+
'image/webp': 'webp',
|
|
40
|
+
};
|
|
41
|
+
class InvalidMarketplaceFile extends errors_1.CLIError {
|
|
42
|
+
constructor(reason) {
|
|
43
|
+
super(`${exports.APP_MARKETPLACE_FILENAME} ${reason}`, { code: 'ERR_INVALID_MARKETPLACE_FILE', exit: 1 });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
exports.InvalidMarketplaceFile = InvalidMarketplaceFile;
|
|
47
|
+
function hashText(text) {
|
|
48
|
+
return crypto.createHash('md5').update(text).digest('hex');
|
|
49
|
+
}
|
|
50
|
+
/** A file as the developer's editor may have saved it — BOM and CRLF are not content. */
|
|
51
|
+
function normalizeText(text) {
|
|
52
|
+
return text.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
|
|
53
|
+
}
|
|
54
|
+
function withTrailingNewline(text) {
|
|
55
|
+
const trimmed = text.trim();
|
|
56
|
+
return trimmed.length === 0 ? '' : `${trimmed}\n`;
|
|
57
|
+
}
|
|
58
|
+
// README.md
|
|
59
|
+
function renderReadme(app, readme) {
|
|
60
|
+
if (readme !== null) {
|
|
61
|
+
return withTrailingNewline(normalizeText(readme));
|
|
62
|
+
}
|
|
63
|
+
const description = app.description?.trim();
|
|
64
|
+
return description ? `# ${app.name}\n\n${description}\n` : `# ${app.name}\n`;
|
|
65
|
+
}
|
|
66
|
+
/** What the file means for the cloud: a blank README clears it. */
|
|
67
|
+
function readmeForPush(text) {
|
|
68
|
+
const normalized = normalizeText(text);
|
|
69
|
+
return normalized.trim().length === 0 ? null : normalized;
|
|
70
|
+
}
|
|
71
|
+
const FRONTMATTER_KEYS = [
|
|
72
|
+
['tagline', 'tagline'],
|
|
73
|
+
['datoCategoryId', 'category_id'],
|
|
74
|
+
['supportLink', 'support_link'],
|
|
75
|
+
['helpLink', 'help_link'],
|
|
76
|
+
];
|
|
77
|
+
function singleLine(text) {
|
|
78
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
79
|
+
}
|
|
80
|
+
function renderMarketplaceFile(app, metadata) {
|
|
81
|
+
const lines = [
|
|
82
|
+
'---',
|
|
83
|
+
`# Marketplace listing of "${singleLine(app.name)}". Edit, then run \`fcode app:push\` from this folder.`,
|
|
84
|
+
'# Below the closing --- is the full description (markdown, 1-5000 chars).',
|
|
85
|
+
`# publication_state: ${app.publicationState} (read-only, set in the dashboard)`,
|
|
86
|
+
];
|
|
87
|
+
if (metadata?.datocmsId) {
|
|
88
|
+
lines.push(`# datocms_id: ${singleLine(metadata.datocmsId)} (linked CMS record, managed by Factorial — read-only)`);
|
|
89
|
+
}
|
|
90
|
+
const fields = Object.fromEntries(FRONTMATTER_KEYS.map(([field, key]) => [key, metadata?.[field] ?? null]));
|
|
91
|
+
lines.push((0, yaml_1.stringify)(fields, { lineWidth: 0 }).trimEnd(), '---');
|
|
92
|
+
return `${lines.join('\n')}\n${withTrailingNewline(metadata?.fullDescription ?? '')}`;
|
|
93
|
+
}
|
|
94
|
+
function frontmatterValue(key, value) {
|
|
95
|
+
if (value === null || value === undefined) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
99
|
+
throw new InvalidMarketplaceFile(`frontmatter key ${key} must be a single text value`);
|
|
100
|
+
}
|
|
101
|
+
const text = String(value);
|
|
102
|
+
return text.trim().length === 0 ? null : text;
|
|
103
|
+
}
|
|
104
|
+
function parseMarketplaceFile(rawText) {
|
|
105
|
+
const empty = { tagline: null, datoCategoryId: null, supportLink: null, helpLink: null };
|
|
106
|
+
const text = normalizeText(rawText);
|
|
107
|
+
if (!text.startsWith('---\n')) {
|
|
108
|
+
return { frontmatter: empty, body: withTrailingNewline(text) };
|
|
109
|
+
}
|
|
110
|
+
const rest = text.slice('---\n'.length);
|
|
111
|
+
const close = rest.match(/^---[ \t]*$/m);
|
|
112
|
+
if (!close || close.index === undefined) {
|
|
113
|
+
throw new InvalidMarketplaceFile('frontmatter is missing its closing ---');
|
|
114
|
+
}
|
|
115
|
+
const block = rest.slice(0, close.index);
|
|
116
|
+
const body = rest.slice(close.index + close[0].length);
|
|
117
|
+
let parsed;
|
|
118
|
+
try {
|
|
119
|
+
parsed = (0, yaml_1.parse)(block);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
throw new InvalidMarketplaceFile(`frontmatter is not valid YAML: ${error.message}`);
|
|
123
|
+
}
|
|
124
|
+
if (parsed !== null && parsed !== undefined && typeof parsed !== 'object') {
|
|
125
|
+
throw new InvalidMarketplaceFile('frontmatter must be a mapping of keys to values');
|
|
126
|
+
}
|
|
127
|
+
const values = (parsed ?? {});
|
|
128
|
+
const frontmatter = { ...empty };
|
|
129
|
+
for (const [field, key] of FRONTMATTER_KEYS) {
|
|
130
|
+
frontmatter[field] = frontmatterValue(key, values[key]);
|
|
131
|
+
}
|
|
132
|
+
return { frontmatter, body: withTrailingNewline(body) };
|
|
133
|
+
}
|
|
134
|
+
/** `'empty'` when nothing was ever written: no description and no field — not an error, just nothing to send. */
|
|
135
|
+
function marketplaceForPush({ frontmatter, body }) {
|
|
136
|
+
const description = body.trim();
|
|
137
|
+
const anyField = Object.values(frontmatter).some((value) => value !== null);
|
|
138
|
+
if (description.length === 0 && !anyField) {
|
|
139
|
+
return 'empty';
|
|
140
|
+
}
|
|
141
|
+
return { fullDescription: description, ...frontmatter };
|
|
142
|
+
}
|
|
143
|
+
// CHANGELOG.md
|
|
144
|
+
function isoDay(iso) {
|
|
145
|
+
return iso ? iso.slice(0, 10) : 'unknown date';
|
|
146
|
+
}
|
|
147
|
+
function byNewest(pick) {
|
|
148
|
+
return (a, b) => (pick(b) ?? b.createdAt).localeCompare(pick(a) ?? a.createdAt);
|
|
149
|
+
}
|
|
150
|
+
function renderDeployed(release, stable) {
|
|
151
|
+
return [
|
|
152
|
+
`## ${release.version} — ${isoDay(release.deployedAt)}${stable ? ' (stable)' : ''}`,
|
|
153
|
+
'',
|
|
154
|
+
release.comments?.trim() ? release.comments.trim() : '_No release notes._',
|
|
155
|
+
'',
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
function renderInProgress(release) {
|
|
159
|
+
const error = release.status === 'failed' && release.error ? `: ${release.error}` : '';
|
|
160
|
+
return `- ${release.version} — ${release.status} (requested ${isoDay(release.createdAt)})${error}`;
|
|
161
|
+
}
|
|
162
|
+
/** Who requested a release stays in the dashboard: the file is meant to be committed. */
|
|
163
|
+
function renderChangelog(app, releases) {
|
|
164
|
+
const lines = [
|
|
165
|
+
'<!-- Generated by `fcode team:pull` from the App\'s releases — edits are overwritten.',
|
|
166
|
+
' Release notes are the comments entered when a release is requested. -->',
|
|
167
|
+
`# Changelog — ${singleLine(app.name)}`,
|
|
168
|
+
'',
|
|
169
|
+
];
|
|
170
|
+
const deployed = releases.filter(({ status }) => status === 'deployed').sort(byNewest((r) => r.deployedAt));
|
|
171
|
+
const inProgress = releases.filter(({ status }) => status !== 'deployed').sort(byNewest(() => null));
|
|
172
|
+
const stable = deployed.filter((r) => r.stableAt !== null).sort(byNewest((r) => r.stableAt))[0];
|
|
173
|
+
if (releases.length === 0) {
|
|
174
|
+
lines.push('_No releases yet._', '');
|
|
175
|
+
}
|
|
176
|
+
for (const release of deployed) {
|
|
177
|
+
lines.push(...renderDeployed(release, release === stable));
|
|
178
|
+
}
|
|
179
|
+
if (inProgress.length > 0) {
|
|
180
|
+
lines.push('## In progress', ...inProgress.map(renderInProgress), '');
|
|
181
|
+
}
|
|
182
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
183
|
+
}
|
|
184
|
+
// marketplace/logo.*
|
|
185
|
+
function existingLogos(appDir) {
|
|
186
|
+
const dir = path.join(appDir, exports.APP_MARKETPLACE_DIR);
|
|
187
|
+
if (!fs.pathExistsSync(dir)) {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
return fs
|
|
191
|
+
.readdirSync(dir)
|
|
192
|
+
.filter((file) => Object.values(LOGO_EXTENSIONS).includes(path.extname(file).slice(1)) && path.parse(file).name === LOGO_BASENAME)
|
|
193
|
+
.map((file) => path.join(dir, file));
|
|
194
|
+
}
|
|
195
|
+
function removeIfEmpty(dir) {
|
|
196
|
+
if (fs.pathExistsSync(dir) && fs.readdirSync(dir).length === 0) {
|
|
197
|
+
fs.removeSync(dir);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** Mirrors the dashboard logo; returns a warning instead of writing when the type is not one we know. */
|
|
201
|
+
function writeAppLogo(appDir, logo) {
|
|
202
|
+
const extension = logo ? LOGO_EXTENSIONS[logo.contentType.split(';')[0].trim()] : undefined;
|
|
203
|
+
if (logo && !extension) {
|
|
204
|
+
return `logo not written: unsupported content type ${logo.contentType}`;
|
|
205
|
+
}
|
|
206
|
+
for (const file of existingLogos(appDir)) {
|
|
207
|
+
fs.removeSync(file);
|
|
208
|
+
}
|
|
209
|
+
if (logo && extension) {
|
|
210
|
+
fs.outputFileSync(path.join(appDir, exports.APP_MARKETPLACE_DIR, `${LOGO_BASENAME}.${extension}`), logo.buffer);
|
|
211
|
+
}
|
|
212
|
+
removeIfEmpty(path.join(appDir, exports.APP_MARKETPLACE_DIR));
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* The part of an editable file the developer owns, in one canonical shape — what the sync hash
|
|
217
|
+
* covers. Comments, YAML quoting, line endings and a BOM are not content, so a listing published
|
|
218
|
+
* from the dashboard or a Windows checkout never reads as an edit. A MARKETPLACE.md whose
|
|
219
|
+
* frontmatter does not parse is compared as raw text: it differs from anything the cloud renders,
|
|
220
|
+
* and `app:push` reports the actual error.
|
|
221
|
+
*/
|
|
222
|
+
function editableContent(file, text) {
|
|
223
|
+
if (file === exports.APP_README_FILENAME) {
|
|
224
|
+
return withTrailingNewline(normalizeText(text));
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
const { frontmatter, body } = parseMarketplaceFile(text);
|
|
228
|
+
return JSON.stringify([...FRONTMATTER_KEYS.map(([field]) => frontmatter[field]), body]);
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
if (error instanceof InvalidMarketplaceFile) {
|
|
232
|
+
return normalizeText(text);
|
|
233
|
+
}
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function hashEditable(file, text) {
|
|
238
|
+
return hashText(editableContent(file, text));
|
|
239
|
+
}
|
|
240
|
+
function readmeRendering(app, cloud) {
|
|
241
|
+
return { file: exports.APP_README_FILENAME, text: renderReadme(app, cloud.readme), generated: cloud.readme === null };
|
|
242
|
+
}
|
|
243
|
+
function marketplaceRendering(app, cloud) {
|
|
244
|
+
return { file: exports.APP_MARKETPLACE_FILENAME, text: renderMarketplaceFile(app, cloud.metadata), generated: false };
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Same model as the workspace settings sync: the stored hash is the last thing both sides agreed
|
|
248
|
+
* on. With none, a file that already matches the cloud is simply adopted; one that differs was
|
|
249
|
+
* written by hand before any sync (`unsynced`). A stub the cloud never held cannot conflict with
|
|
250
|
+
* a local edit — there is nothing in the cloud to lose.
|
|
251
|
+
*/
|
|
252
|
+
function metadataSyncState(localText, storedHash, cloud) {
|
|
253
|
+
const local = editableContent(cloud.file, localText);
|
|
254
|
+
const remote = editableContent(cloud.file, cloud.text);
|
|
255
|
+
if (storedHash === undefined) {
|
|
256
|
+
return local === remote ? 'up-to-date' : 'unsynced';
|
|
257
|
+
}
|
|
258
|
+
const localChanged = hashText(local) !== storedHash;
|
|
259
|
+
const cloudChanged = hashText(remote) !== storedHash;
|
|
260
|
+
if (localChanged && cloudChanged) {
|
|
261
|
+
return cloud.generated ? 'modified' : 'conflict';
|
|
262
|
+
}
|
|
263
|
+
if (localChanged) {
|
|
264
|
+
return 'modified';
|
|
265
|
+
}
|
|
266
|
+
return cloudChanged ? 'out-of-date' : 'up-to-date';
|
|
267
|
+
}
|
|
268
|
+
/** Writes the file as the cloud renders it and returns the hash to record for it. */
|
|
269
|
+
function writeEditable(appDir, rendering) {
|
|
270
|
+
fs.outputFileSync(path.join(appDir, rendering.file), rendering.text);
|
|
271
|
+
return hashEditable(rendering.file, rendering.text);
|
|
272
|
+
}
|
|
273
|
+
function keptWarning(state, file, folder) {
|
|
274
|
+
switch (state) {
|
|
275
|
+
case 'unsynced':
|
|
276
|
+
return `${file} exists but was never synced — kept; run fcode app:push to upload it, or delete it to take the cloud version`;
|
|
277
|
+
case 'conflict':
|
|
278
|
+
return `${file} changed both locally and in the cloud — kept; fcode app:push --force in ${folder} to overwrite the cloud, or delete it and fcode team:pull to take the cloud version`;
|
|
279
|
+
default:
|
|
280
|
+
return `${file} edited locally — kept; run fcode app:push in ${folder} to upload it`;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function applyEditable(appDir, rendering, storedHash) {
|
|
284
|
+
const filePath = path.join(appDir, rendering.file);
|
|
285
|
+
if (!fs.pathExistsSync(filePath)) {
|
|
286
|
+
return { hash: writeEditable(appDir, rendering) };
|
|
287
|
+
}
|
|
288
|
+
const local = fs.readFileSync(filePath, 'utf8');
|
|
289
|
+
const state = metadataSyncState(local, storedHash, rendering);
|
|
290
|
+
switch (state) {
|
|
291
|
+
case 'up-to-date':
|
|
292
|
+
// Same content, possibly stale read-only comments: refresh those without touching the state.
|
|
293
|
+
if (normalizeText(local) !== rendering.text) {
|
|
294
|
+
fs.outputFileSync(filePath, rendering.text);
|
|
295
|
+
}
|
|
296
|
+
return { hash: hashEditable(rendering.file, rendering.text) };
|
|
297
|
+
case 'out-of-date':
|
|
298
|
+
return { hash: writeEditable(appDir, rendering) };
|
|
299
|
+
default:
|
|
300
|
+
return { warning: keptWarning(state, rendering.file, path.basename(appDir)) };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function settledOrWarn(result, file, warnings) {
|
|
304
|
+
if (result.status === 'fulfilled') {
|
|
305
|
+
return result.value;
|
|
306
|
+
}
|
|
307
|
+
warnings.push(`${file} not refreshed: ${result.reason.message}`);
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Lays the metadata files out for one App. Never throws: a file the dashboard could not serve is
|
|
312
|
+
* left as it was and reported, so a metadata hiccup cannot fail the clone of the workspace.
|
|
313
|
+
*/
|
|
314
|
+
async function syncAppMetadata(source, appDir, app, recorded) {
|
|
315
|
+
const warnings = [];
|
|
316
|
+
const [readme, marketplace, releases, logo] = await Promise.allSettled([
|
|
317
|
+
source.getAppReadme(app.id),
|
|
318
|
+
source.getAppMarketplace(app.id),
|
|
319
|
+
source.listAppReleases(app.id),
|
|
320
|
+
app.logoContentType === null ? Promise.resolve(null) : source.getAppLogo(app.id),
|
|
321
|
+
]);
|
|
322
|
+
const editable = (rendering, storedHash) => {
|
|
323
|
+
const outcome = applyEditable(appDir, rendering, storedHash);
|
|
324
|
+
if (outcome.warning) {
|
|
325
|
+
warnings.push(outcome.warning);
|
|
326
|
+
}
|
|
327
|
+
return outcome.hash ?? storedHash;
|
|
328
|
+
};
|
|
329
|
+
const cloudReadme = settledOrWarn(readme, exports.APP_README_FILENAME, warnings);
|
|
330
|
+
if (cloudReadme) {
|
|
331
|
+
recorded.readmeHash = editable(readmeRendering(app, cloudReadme), recorded.readmeHash);
|
|
332
|
+
}
|
|
333
|
+
const cloudMarketplace = settledOrWarn(marketplace, exports.APP_MARKETPLACE_FILENAME, warnings);
|
|
334
|
+
if (cloudMarketplace) {
|
|
335
|
+
recorded.marketplaceHash = editable(marketplaceRendering(app, cloudMarketplace), recorded.marketplaceHash);
|
|
336
|
+
}
|
|
337
|
+
const cloudReleases = settledOrWarn(releases, exports.APP_CHANGELOG_FILENAME, warnings);
|
|
338
|
+
if (cloudReleases) {
|
|
339
|
+
(0, file_utils_1.outputFileSyncIfChanged)(path.join(appDir, exports.APP_CHANGELOG_FILENAME), renderChangelog(app, cloudReleases));
|
|
340
|
+
}
|
|
341
|
+
const cloudLogo = settledOrWarn(logo, `${exports.APP_MARKETPLACE_DIR}/${LOGO_BASENAME}`, warnings);
|
|
342
|
+
if (cloudLogo !== undefined) {
|
|
343
|
+
const warning = writeAppLogo(appDir, cloudLogo);
|
|
344
|
+
if (warning) {
|
|
345
|
+
warnings.push(warning);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return { warnings };
|
|
349
|
+
}
|
|
350
|
+
/** What `team:status` and `app:push` report for the two editable files, given the cloud renderings. */
|
|
351
|
+
function metadataStatus(appDir, app, recorded, cloud) {
|
|
352
|
+
const files = [
|
|
353
|
+
[readmeRendering(app, cloud.readme), recorded.readmeHash],
|
|
354
|
+
[marketplaceRendering(app, cloud.marketplace), recorded.marketplaceHash],
|
|
355
|
+
];
|
|
356
|
+
return files.map(([rendering, storedHash]) => {
|
|
357
|
+
const filePath = path.join(appDir, rendering.file);
|
|
358
|
+
if (!fs.pathExistsSync(filePath)) {
|
|
359
|
+
return { file: rendering.file, state: 'missing' };
|
|
360
|
+
}
|
|
361
|
+
return { file: rendering.file, state: metadataSyncState(fs.readFileSync(filePath, 'utf8'), storedHash, rendering) };
|
|
362
|
+
});
|
|
363
|
+
}
|
|
@@ -3,10 +3,67 @@ export interface DevelopmentTeam {
|
|
|
3
3
|
id: string;
|
|
4
4
|
name: string;
|
|
5
5
|
}
|
|
6
|
+
export type AppPublicationState = 'draft' | 'private' | 'public';
|
|
6
7
|
export interface DevelopmentTeamApp {
|
|
7
8
|
id: string;
|
|
8
9
|
name: string;
|
|
9
10
|
description: string | null;
|
|
11
|
+
publicationState: AppPublicationState;
|
|
12
|
+
/** png/jpeg/webp when the App has a logo; null means there is none to fetch. */
|
|
13
|
+
logoContentType: string | null;
|
|
14
|
+
}
|
|
15
|
+
export interface AppReadme {
|
|
16
|
+
readme: string | null;
|
|
17
|
+
}
|
|
18
|
+
/** The developer-editable half of the marketplace listing, as the dashboard stores it. */
|
|
19
|
+
export interface AppMarketplaceInput {
|
|
20
|
+
fullDescription: string;
|
|
21
|
+
tagline: string | null;
|
|
22
|
+
datoCategoryId: string | null;
|
|
23
|
+
supportLink: string | null;
|
|
24
|
+
helpLink: string | null;
|
|
25
|
+
}
|
|
26
|
+
export interface AppMarketplaceMetadata extends AppMarketplaceInput {
|
|
27
|
+
/** Linked CMS record, managed by Factorial; read-only. */
|
|
28
|
+
datocmsId: string | null;
|
|
29
|
+
}
|
|
30
|
+
export interface AppMarketplace {
|
|
31
|
+
/** Null until the listing is first saved. */
|
|
32
|
+
metadata: AppMarketplaceMetadata | null;
|
|
33
|
+
}
|
|
34
|
+
export type AppReleaseStatus = 'requested' | 'deploying' | 'deployed' | 'failed';
|
|
35
|
+
export interface AppRelease {
|
|
36
|
+
id: string;
|
|
37
|
+
version: string;
|
|
38
|
+
comments: string | null;
|
|
39
|
+
status: AppReleaseStatus;
|
|
40
|
+
error: string | null;
|
|
41
|
+
requestedBy: string | null;
|
|
42
|
+
deployedAt: string | null;
|
|
43
|
+
stableAt: string | null;
|
|
44
|
+
createdAt: string;
|
|
45
|
+
}
|
|
46
|
+
export interface AppLogo {
|
|
47
|
+
buffer: Buffer;
|
|
48
|
+
contentType: string;
|
|
49
|
+
}
|
|
50
|
+
/** A non-2xx answer from the dashboard, carrying the RFC 9457 problem details it sent. */
|
|
51
|
+
export declare class DashboardApiError extends Error {
|
|
52
|
+
readonly status: number;
|
|
53
|
+
readonly path: string;
|
|
54
|
+
readonly detail: string;
|
|
55
|
+
readonly code?: string | undefined;
|
|
56
|
+
constructor(status: number, path: string, detail: string, code?: string | undefined);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A base workspace the team owns (`base-app` and the like), as opposed to an App's `dev-` workspace.
|
|
60
|
+
* The slug is the platform team slug itself: fixed, filesystem-safe, and unique.
|
|
61
|
+
*/
|
|
62
|
+
export interface DevelopmentTeamGlobalWorkspace {
|
|
63
|
+
id: string;
|
|
64
|
+
name: string;
|
|
65
|
+
slug: string;
|
|
66
|
+
description: string;
|
|
10
67
|
}
|
|
11
68
|
/**
|
|
12
69
|
* `https://code.factorialhr.com/platform` → `https://code.factorialhr.com/dashboard/api/v1`.
|
|
@@ -24,8 +81,22 @@ export default class DashboardClient {
|
|
|
24
81
|
listDevelopmentTeams(): Promise<DevelopmentTeam[]>;
|
|
25
82
|
/** The Apps owned by a development team, narrowed by the API to the ones the session can see. */
|
|
26
83
|
listTeamApps(teamId: string): Promise<DevelopmentTeamApp[]>;
|
|
84
|
+
getApp(appId: string): Promise<DevelopmentTeamApp>;
|
|
85
|
+
getAppReadme(appId: string): Promise<AppReadme>;
|
|
86
|
+
putAppReadme(appId: string, readme: string | null): Promise<AppReadme>;
|
|
87
|
+
getAppMarketplace(appId: string): Promise<AppMarketplace>;
|
|
88
|
+
/** Full replace: every field travels, nulls included, since an absent key clears on the dashboard. */
|
|
89
|
+
putAppMarketplace(appId: string, input: AppMarketplaceInput): Promise<AppMarketplaceMetadata>;
|
|
90
|
+
listAppReleases(appId: string): Promise<AppRelease[]>;
|
|
91
|
+
/** The App's logo bytes, or null when it has none. */
|
|
92
|
+
getAppLogo(appId: string): Promise<AppLogo | null>;
|
|
93
|
+
/** The global workspaces owned by a development team. */
|
|
94
|
+
listTeamGlobalWorkspaces(teamId: string): Promise<DevelopmentTeamGlobalWorkspace[]>;
|
|
27
95
|
private getAllPages;
|
|
28
96
|
private get;
|
|
97
|
+
private request;
|
|
98
|
+
private getBinary;
|
|
99
|
+
private send;
|
|
29
100
|
private headers;
|
|
30
101
|
private errorDetail;
|
|
31
102
|
}
|