@factorialco/fcode-cli 2.1.0 → 2.3.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 +128 -2
- package/dist/commands/add.js +3 -1
- package/dist/commands/clone.d.ts +1 -0
- package/dist/commands/clone.js +19 -1
- package/dist/commands/i18n/add.d.ts +9 -0
- package/dist/commands/i18n/add.js +13 -0
- package/dist/commands/i18n/pull.d.ts +12 -0
- package/dist/commands/i18n/pull.js +14 -0
- package/dist/commands/i18n/push.d.ts +12 -0
- package/dist/commands/i18n/push.js +14 -0
- package/dist/commands/i18n/remove.d.ts +9 -0
- package/dist/commands/i18n/remove.js +19 -0
- package/dist/commands/i18n/reset.d.ts +9 -0
- package/dist/commands/i18n/reset.js +13 -0
- package/dist/commands/i18n/status.d.ts +11 -0
- package/dist/commands/i18n/status.js +42 -0
- package/dist/commands/pull.js +5 -3
- package/dist/commands/push.js +5 -3
- package/dist/commands/reset.js +3 -1
- package/dist/commands/run.d.ts +1 -0
- package/dist/commands/run.js +5 -2
- package/dist/commands/status.js +4 -2
- package/dist/commands/team/push.js +3 -0
- package/dist/config/remote.js +1 -1
- package/dist/config/workspace.js +10 -0
- package/dist/core/commands/api-client-command.d.ts +1 -0
- package/dist/core/commands/api-client-command.js +5 -0
- package/dist/core/commands/base-command.d.ts +2 -0
- package/dist/core/commands/base-command.js +31 -0
- package/dist/core/commands/i18n-command.d.ts +5 -0
- package/dist/core/commands/i18n-command.js +16 -0
- package/dist/core/errors.d.ts +10 -0
- package/dist/core/errors.js +34 -1
- package/dist/core/executors/execution-task.js +4 -0
- package/dist/core/executors/javascript-execution-task.js +26 -1
- package/dist/core/resource-pusher.js +15 -0
- package/dist/core/types.d.ts +29 -1
- package/dist/core/types.js +46 -3
- package/dist/repositories/locale.d.ts +24 -0
- package/dist/repositories/locale.js +102 -0
- package/dist/repositories/resource.js +4 -1
- package/dist/repositories/team.js +5 -2
- package/dist/runtime/libs/python/fcode/__init__.py +1 -0
- package/dist/runtime/libs/python/fcode/context.py +43 -1
- package/dist/services/auth-service.d.ts +1 -0
- package/dist/services/auth-service.js +12 -0
- package/dist/services/fcode-graphql-client.d.ts +13 -1
- package/dist/services/fcode-graphql-client.js +136 -0
- package/dist/services/locale-merger.d.ts +23 -0
- package/dist/services/locale-merger.js +114 -0
- package/dist/services/locale-resolver.d.ts +18 -0
- package/dist/services/locale-resolver.js +120 -0
- package/dist/services/process-runner.d.ts +1 -0
- package/dist/services/process-runner.js +2 -2
- package/dist/services/team-access.d.ts +13 -0
- package/dist/services/team-access.js +45 -0
- package/dist/utils/gitignore.d.ts +1 -1
- package/dist/utils/gitignore.js +4 -1
- package/oclif.manifest.json +194 -1
- package/package.json +2 -1
|
@@ -8,6 +8,7 @@ declare const AuthService: {
|
|
|
8
8
|
isAuthenticated(url: string): Promise<boolean>;
|
|
9
9
|
isTokenExpiring(token: Token): boolean;
|
|
10
10
|
refreshToken(authUrl: string, refreshToken: string): Promise<Token>;
|
|
11
|
+
refreshSession(url: string): Promise<boolean>;
|
|
11
12
|
tryRefresh(url: string, { authUrl, token, userInfo }: AuthInfo): Promise<boolean>;
|
|
12
13
|
login({ url, authUrl, email, password, clientId, clientSecret, accessToken }: Credentials): Promise<AuthInfo>;
|
|
13
14
|
logout(url: string): Promise<void>;
|
|
@@ -117,6 +117,18 @@ const AuthService = {
|
|
|
117
117
|
}
|
|
118
118
|
throw token.error_description;
|
|
119
119
|
},
|
|
120
|
+
// Force a re-mint of the access token regardless of expiry. Team groups are
|
|
121
|
+
// snapshotted into the token at issuance, so a token created before a team
|
|
122
|
+
// existed (or before the user joined it) 403s on it until re-minted (FCT-61231).
|
|
123
|
+
async refreshSession(url) {
|
|
124
|
+
try {
|
|
125
|
+
const authInfo = await this.globalConfig.getAuthInfo(url);
|
|
126
|
+
return await this.tryRefresh(url, authInfo);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
},
|
|
120
132
|
async tryRefresh(url, { authUrl, token, userInfo }) {
|
|
121
133
|
if (!token.refresh_token) {
|
|
122
134
|
return false;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { GraphQLClient } from 'graphql-request';
|
|
2
|
-
import { Process, ProgrammingLanguage, ProgrammingLanguageManifest, RawTeamSettings, ScriptLibrary, TeamUpdateInput, TeamVersion, TeamVersionAlias, Variable, VersionedProcess, VersionedScriptLibrary, ServiceAccount } from '../core/types';
|
|
2
|
+
import { Locale, Process, ProgrammingLanguage, ProgrammingLanguageManifest, RawTeamSettings, ScriptLibrary, TeamUpdateInput, TeamVersion, TeamVersionAlias, Variable, VersionedProcess, VersionedScriptLibrary, ServiceAccount } from '../core/types';
|
|
3
3
|
export type FcodeGraphQLClientOptions = {
|
|
4
4
|
url: string;
|
|
5
5
|
accessToken: string;
|
|
@@ -10,6 +10,7 @@ declare class FcodeGraphQLClient {
|
|
|
10
10
|
private teamSlug;
|
|
11
11
|
private url;
|
|
12
12
|
constructor(options: FcodeGraphQLClientOptions);
|
|
13
|
+
setAccessToken(accessToken: string): void;
|
|
13
14
|
isDuplicatedError(error: any): boolean;
|
|
14
15
|
isEmptyValueError(error: any): boolean;
|
|
15
16
|
cleanError(error: any): any;
|
|
@@ -22,6 +23,16 @@ declare class FcodeGraphQLClient {
|
|
|
22
23
|
allVersionedScriptLibraries(scriptLibraryId: string): Promise<Array<VersionedScriptLibrary>>;
|
|
23
24
|
createVersionedScriptLibrary(versionedScriptLibrary: VersionedScriptLibrary): Promise<void>;
|
|
24
25
|
allVariables(): Promise<Array<Variable>>;
|
|
26
|
+
/**
|
|
27
|
+
* Every locale the workspace resolves: its own files, plus one merged file per identifier it
|
|
28
|
+
* inherits. A locale the workspace and a parent both define is listed twice by the platform and
|
|
29
|
+
* kept twice here — an override is layered over the parent's file key by key, so the parent's
|
|
30
|
+
* keys are still what the ones it does not mention resolve to.
|
|
31
|
+
*/
|
|
32
|
+
allLocales(): Promise<Array<Locale>>;
|
|
33
|
+
pushLocale(locale: Locale): Promise<Locale>;
|
|
34
|
+
updateLocale(locale: Locale): Promise<Locale>;
|
|
35
|
+
removeLocale(locale: string | undefined): Promise<void>;
|
|
25
36
|
private toProcessInput;
|
|
26
37
|
pushProcess(process: Process): Promise<Process>;
|
|
27
38
|
removeProcess(id: String | undefined): Promise<Process>;
|
|
@@ -53,6 +64,7 @@ declare class FcodeGraphQLClient {
|
|
|
53
64
|
/** The API upserts: an existing alias with the same name is re-pointed to the tag. */
|
|
54
65
|
createTeamVersionAlias(name: string, tag: string): Promise<TeamVersionAlias>;
|
|
55
66
|
deleteTeamVersionAlias(id: string): Promise<TeamVersionAlias>;
|
|
67
|
+
verifyTeamAccess(): Promise<void>;
|
|
56
68
|
team(): Promise<RawTeamSettings>;
|
|
57
69
|
pushTeam(update: TeamUpdateInput): Promise<RawTeamSettings>;
|
|
58
70
|
mapProgrammingLanguageManifest(programmingLanguageManifest: any): ProgrammingLanguageManifest;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const graphql_request_1 = require("graphql-request");
|
|
4
4
|
const types_1 = require("../core/types");
|
|
5
|
+
const locale_merger_1 = require("./locale-merger");
|
|
5
6
|
const _ = require("lodash");
|
|
6
7
|
// Shared team version selections so every query/mutation returns the same shape.
|
|
7
8
|
const TEAM_VERSION_FIELDS = `
|
|
@@ -89,6 +90,11 @@ class FcodeGraphQLClient {
|
|
|
89
90
|
},
|
|
90
91
|
});
|
|
91
92
|
}
|
|
93
|
+
// Swap the bearer token on the live client (after a session refresh) so every
|
|
94
|
+
// holder of this instance — repositories, pushers, queries — uses the new one.
|
|
95
|
+
setAccessToken(accessToken) {
|
|
96
|
+
this.client.setHeader('authorization', `Bearer ${accessToken}`);
|
|
97
|
+
}
|
|
92
98
|
isDuplicatedError(error) {
|
|
93
99
|
return (error &&
|
|
94
100
|
error.response &&
|
|
@@ -302,6 +308,114 @@ class FcodeGraphQLClient {
|
|
|
302
308
|
}
|
|
303
309
|
return variables;
|
|
304
310
|
}
|
|
311
|
+
/**
|
|
312
|
+
* Every locale the workspace resolves: its own files, plus one merged file per identifier it
|
|
313
|
+
* inherits. A locale the workspace and a parent both define is listed twice by the platform and
|
|
314
|
+
* kept twice here — an override is layered over the parent's file key by key, so the parent's
|
|
315
|
+
* keys are still what the ones it does not mention resolve to.
|
|
316
|
+
*/
|
|
317
|
+
async allLocales() {
|
|
318
|
+
const pageSize = 200;
|
|
319
|
+
const locales = [];
|
|
320
|
+
let after = null;
|
|
321
|
+
try {
|
|
322
|
+
do {
|
|
323
|
+
const { locales: { edges, pageInfo }, } = await this.client.request((0, graphql_request_1.gql) `
|
|
324
|
+
query locales($first: Int!, $after: String) {
|
|
325
|
+
locales(first: $first, after: $after) {
|
|
326
|
+
edges {
|
|
327
|
+
node {
|
|
328
|
+
id
|
|
329
|
+
locale
|
|
330
|
+
content
|
|
331
|
+
resolvingTeamSlug
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
pageInfo {
|
|
335
|
+
hasNextPage
|
|
336
|
+
endCursor
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
`, {
|
|
341
|
+
after,
|
|
342
|
+
first: pageSize,
|
|
343
|
+
});
|
|
344
|
+
for (const { node: { id, locale, content, resolvingTeamSlug }, } of edges) {
|
|
345
|
+
locales.push(new types_1.Locale(id, locale, content, this.inheritedTeam(resolvingTeamSlug)));
|
|
346
|
+
}
|
|
347
|
+
after = pageInfo?.hasNextPage ? pageInfo.endCursor : null;
|
|
348
|
+
} while (after);
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
return Promise.reject(this.cleanError(error));
|
|
352
|
+
}
|
|
353
|
+
// The cloud team's parent order is the inheritance precedence the merge must replicate; the
|
|
354
|
+
// local team.json may lag behind it, so the cloud is asked, not the file.
|
|
355
|
+
const { parentTeamSlugs } = await this.team();
|
|
356
|
+
const merged = (0, locale_merger_1.mergeInheritedLocales)(locales, parentTeamSlugs ?? []);
|
|
357
|
+
(0, locale_merger_1.assertLocaleFilesCanCoexist)(merged);
|
|
358
|
+
return merged;
|
|
359
|
+
}
|
|
360
|
+
pushLocale(locale) {
|
|
361
|
+
return this.client
|
|
362
|
+
.request((0, graphql_request_1.gql) `
|
|
363
|
+
mutation createLocale($locale: String!, $content: String!) {
|
|
364
|
+
createLocale(locale: $locale, content: $content) {
|
|
365
|
+
id
|
|
366
|
+
locale
|
|
367
|
+
content
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
`, {
|
|
371
|
+
locale: locale.locale,
|
|
372
|
+
content: locale.content,
|
|
373
|
+
})
|
|
374
|
+
.then(({ createLocale: created }) => new types_1.Locale(created.id, created.locale, created.content))
|
|
375
|
+
.catch((error) => {
|
|
376
|
+
return Promise.reject(this.cleanError(error));
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
updateLocale(locale) {
|
|
380
|
+
return this.client
|
|
381
|
+
.request((0, graphql_request_1.gql) `
|
|
382
|
+
mutation updateLocale($locale: String!, $content: String!) {
|
|
383
|
+
updateLocale(locale: $locale, content: $content) {
|
|
384
|
+
id
|
|
385
|
+
locale
|
|
386
|
+
content
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
`, {
|
|
390
|
+
locale: locale.locale,
|
|
391
|
+
content: locale.content,
|
|
392
|
+
})
|
|
393
|
+
.then(({ updateLocale: updated }) => new types_1.Locale(updated.id, updated.locale, updated.content))
|
|
394
|
+
.catch((error) => {
|
|
395
|
+
return Promise.reject(this.cleanError(error));
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
// Addressed by identifier rather than id: a locale is its file, and the delete path is reached
|
|
399
|
+
// from a file that is gone, whose recorded id may predate an override.
|
|
400
|
+
removeLocale(locale) {
|
|
401
|
+
if (!locale) {
|
|
402
|
+
return Promise.reject(new Error('locale is required during remove'));
|
|
403
|
+
}
|
|
404
|
+
return this.client
|
|
405
|
+
.request((0, graphql_request_1.gql) `
|
|
406
|
+
mutation deleteLocale($locale: String!) {
|
|
407
|
+
deleteLocale(locale: $locale) {
|
|
408
|
+
id
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
`, {
|
|
412
|
+
locale,
|
|
413
|
+
})
|
|
414
|
+
.then(() => undefined)
|
|
415
|
+
.catch((error) => {
|
|
416
|
+
return Promise.reject(this.cleanError(error));
|
|
417
|
+
});
|
|
418
|
+
}
|
|
305
419
|
toProcessInput(process, includeCreateOnly) {
|
|
306
420
|
const settings = {
|
|
307
421
|
dependencies: { scopedToProcess: process.settings.dependencies.scopedToProcess },
|
|
@@ -639,6 +753,9 @@ class FcodeGraphQLClient {
|
|
|
639
753
|
settings.webhookAuth.headerName = webhookAuth.headerName;
|
|
640
754
|
}
|
|
641
755
|
}
|
|
756
|
+
if (team.settings?.primaryLocale) {
|
|
757
|
+
settings.primaryLocale = team.settings.primaryLocale;
|
|
758
|
+
}
|
|
642
759
|
return settings;
|
|
643
760
|
}
|
|
644
761
|
mapTeamVersion(teamVersion) {
|
|
@@ -751,6 +868,23 @@ class FcodeGraphQLClient {
|
|
|
751
868
|
return Promise.reject(this.cleanError(error));
|
|
752
869
|
});
|
|
753
870
|
}
|
|
871
|
+
// Cheapest team-scoped query, used as a pre-flight access check so commands can
|
|
872
|
+
// fail once with a clear error before touching local state, instead of erroring
|
|
873
|
+
// on every resource pull.
|
|
874
|
+
verifyTeamAccess() {
|
|
875
|
+
return this.client
|
|
876
|
+
.request((0, graphql_request_1.gql) `
|
|
877
|
+
query team($slug: ID!) {
|
|
878
|
+
team(slug: $slug) {
|
|
879
|
+
slug
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
`, { slug: this.teamSlug })
|
|
883
|
+
.then(() => undefined)
|
|
884
|
+
.catch((error) => {
|
|
885
|
+
return Promise.reject(this.cleanError(error));
|
|
886
|
+
});
|
|
887
|
+
}
|
|
754
888
|
team() {
|
|
755
889
|
return this.client
|
|
756
890
|
.request((0, graphql_request_1.gql) `
|
|
@@ -768,6 +902,7 @@ class FcodeGraphQLClient {
|
|
|
768
902
|
headerName
|
|
769
903
|
variableKey
|
|
770
904
|
}
|
|
905
|
+
primaryLocale
|
|
771
906
|
}
|
|
772
907
|
}
|
|
773
908
|
}
|
|
@@ -794,6 +929,7 @@ class FcodeGraphQLClient {
|
|
|
794
929
|
headerName
|
|
795
930
|
variableKey
|
|
796
931
|
}
|
|
932
|
+
primaryLocale
|
|
797
933
|
}
|
|
798
934
|
}
|
|
799
935
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Locale } from '../core/types';
|
|
2
|
+
/**
|
|
3
|
+
* Collapses the locale files a workspace inherits into one file per identifier.
|
|
4
|
+
*
|
|
5
|
+
* The cloud lists a locale once per workspace that defines it, and every one of them is live: an
|
|
6
|
+
* override is layered over its parent key by key, so a locale several workspaces define is several
|
|
7
|
+
* files. A workspace directory keeps one inherited file per identifier instead — one file cannot
|
|
8
|
+
* hold two names — so the parents' files are merged here, in the order the cloud resolves them.
|
|
9
|
+
*
|
|
10
|
+
* A single parent's file is kept verbatim, comments and nesting included. Only a locale that
|
|
11
|
+
* several parents define is rewritten, as a flat mapping of dotted keys, which is the shape the
|
|
12
|
+
* lookup uses anyway and the one shape where a nested and a dotted key cannot disagree.
|
|
13
|
+
*/
|
|
14
|
+
export declare function mergeInheritedLocales(locales: Array<Locale>, parentTeamSlugs: Array<string>): Array<Locale>;
|
|
15
|
+
/**
|
|
16
|
+
* Locale identifiers are case-sensitive on the platform (pt-BR and pt-br are different locales),
|
|
17
|
+
* but the filesystems macOS and Windows usually run on are not — both identifiers would collapse
|
|
18
|
+
* into one file, so a pull would write one locale over the other, `status` would report a phantom
|
|
19
|
+
* change forever, and the next push would silently overwrite one locale's cloud content with the
|
|
20
|
+
* other's. Refused outright before anything is written; on a case-sensitive filesystem the files
|
|
21
|
+
* live side by side and nothing is refused.
|
|
22
|
+
*/
|
|
23
|
+
export declare function assertLocaleFilesCanCoexist(locales: Array<Locale>, filesystemKeepsCase?: boolean): void;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.mergeInheritedLocales = mergeInheritedLocales;
|
|
4
|
+
exports.assertLocaleFilesCanCoexist = assertLocaleFilesCanCoexist;
|
|
5
|
+
const fs = require("fs-extra");
|
|
6
|
+
const os = require("node:os");
|
|
7
|
+
const path = require("node:path");
|
|
8
|
+
const yaml_1 = require("yaml");
|
|
9
|
+
const color_1 = require("@oclif/color");
|
|
10
|
+
const cli_1 = require("../utils/cli");
|
|
11
|
+
const types_1 = require("../core/types");
|
|
12
|
+
const locale_resolver_1 = require("./locale-resolver");
|
|
13
|
+
/**
|
|
14
|
+
* Collapses the locale files a workspace inherits into one file per identifier.
|
|
15
|
+
*
|
|
16
|
+
* The cloud lists a locale once per workspace that defines it, and every one of them is live: an
|
|
17
|
+
* override is layered over its parent key by key, so a locale several workspaces define is several
|
|
18
|
+
* files. A workspace directory keeps one inherited file per identifier instead — one file cannot
|
|
19
|
+
* hold two names — so the parents' files are merged here, in the order the cloud resolves them.
|
|
20
|
+
*
|
|
21
|
+
* A single parent's file is kept verbatim, comments and nesting included. Only a locale that
|
|
22
|
+
* several parents define is rewritten, as a flat mapping of dotted keys, which is the shape the
|
|
23
|
+
* lookup uses anyway and the one shape where a nested and a dotted key cannot disagree.
|
|
24
|
+
*/
|
|
25
|
+
function mergeInheritedLocales(locales, parentTeamSlugs) {
|
|
26
|
+
const inheritedByIdentifier = new Map();
|
|
27
|
+
for (const locale of locales.filter((locale) => locale.resolvingTeamSlug)) {
|
|
28
|
+
inheritedByIdentifier.set(locale.locale, [...(inheritedByIdentifier.get(locale.locale) ?? []), locale]);
|
|
29
|
+
}
|
|
30
|
+
return [
|
|
31
|
+
...locales.filter((locale) => !locale.resolvingTeamSlug),
|
|
32
|
+
...[...inheritedByIdentifier.values()].map((files) => files.length === 1 ? files[0] : mergeLocaleFiles(files, parentTeamSlugs)),
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The platform walks parents in the team's configured order — `Team.parentTeamIdsInResolutionOrder`
|
|
37
|
+
* — and the first defining a key wins. Deriving that order here from the cloud team's
|
|
38
|
+
* `parentTeamSlugs`, rather than trusting the order the API happened to list the rows in (which
|
|
39
|
+
* follows the listing's `orderBy`, `updatedAt` by default), is what keeps a local run and the
|
|
40
|
+
* cloud resolving the same text.
|
|
41
|
+
*/
|
|
42
|
+
function inResolutionOrder(files, parentTeamSlugs) {
|
|
43
|
+
const precedence = (file) => {
|
|
44
|
+
const index = parentTeamSlugs.indexOf(file.resolvingTeamSlug ?? '');
|
|
45
|
+
return index === -1 ? parentTeamSlugs.length : index;
|
|
46
|
+
};
|
|
47
|
+
return [...files].sort((a, b) => precedence(a) - precedence(b));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* @param files the same locale as every parent workspace defines it, in any order
|
|
51
|
+
* @param parentTeamSlugs the workspace's parents in inheritance-precedence order
|
|
52
|
+
* @returns one locale carrying their merged translations, attributed to the winning parent
|
|
53
|
+
*/
|
|
54
|
+
function mergeLocaleFiles(files, parentTeamSlugs) {
|
|
55
|
+
const ordered = inResolutionOrder(files, parentTeamSlugs);
|
|
56
|
+
const [winner] = ordered;
|
|
57
|
+
const translations = {};
|
|
58
|
+
// Lowest precedence first, so the first parent in resolution order is the text that survives —
|
|
59
|
+
// the same layering the platform applies.
|
|
60
|
+
for (const file of [...ordered].reverse()) {
|
|
61
|
+
Object.assign(translations, translationsOf(file));
|
|
62
|
+
}
|
|
63
|
+
return new types_1.Locale(winner.id, winner.locale, `${mergeHeader(ordered)}${(0, yaml_1.stringify)(translations)}`, winner.resolvingTeamSlug);
|
|
64
|
+
}
|
|
65
|
+
function translationsOf(file) {
|
|
66
|
+
try {
|
|
67
|
+
return (0, locale_resolver_1.flattenTranslations)((0, yaml_1.parse)(file.content));
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
// Skipped rather than fatal: a workspace cannot fix a parent's file, and the cloud ignores an
|
|
71
|
+
// unparseable one too.
|
|
72
|
+
cli_1.default.log(color_1.default.yellow(`\n⚠️ locale '${file.locale}' from workspace '${file.resolvingTeamSlug}' could not be parsed and will be ignored: ${error.message}`));
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Locale identifiers are case-sensitive on the platform (pt-BR and pt-br are different locales),
|
|
78
|
+
* but the filesystems macOS and Windows usually run on are not — both identifiers would collapse
|
|
79
|
+
* into one file, so a pull would write one locale over the other, `status` would report a phantom
|
|
80
|
+
* change forever, and the next push would silently overwrite one locale's cloud content with the
|
|
81
|
+
* other's. Refused outright before anything is written; on a case-sensitive filesystem the files
|
|
82
|
+
* live side by side and nothing is refused.
|
|
83
|
+
*/
|
|
84
|
+
function assertLocaleFilesCanCoexist(locales, filesystemKeepsCase = filesystemKeepsCaseApart()) {
|
|
85
|
+
const slugsByFoldedSlug = new Map();
|
|
86
|
+
for (const locale of locales) {
|
|
87
|
+
const slug = locale.slug ?? locale.locale;
|
|
88
|
+
const folded = slug.toLowerCase();
|
|
89
|
+
slugsByFoldedSlug.set(folded, [...(slugsByFoldedSlug.get(folded) ?? []), slug]);
|
|
90
|
+
}
|
|
91
|
+
const collisions = [...slugsByFoldedSlug.values()].filter((slugs) => slugs.length > 1);
|
|
92
|
+
if (collisions.length === 0 || filesystemKeepsCase) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const described = collisions.map((slugs) => `'${slugs.join("' / '")}'`).join(', ');
|
|
96
|
+
throw new Error(`locales differing only in case cannot both be stored on this filesystem: ${described}. ` +
|
|
97
|
+
'Rename or remove one of them in the cloud and pull again.');
|
|
98
|
+
}
|
|
99
|
+
function filesystemKeepsCaseApart() {
|
|
100
|
+
const probe = fs.mkdtempSync(path.join(os.tmpdir(), 'fcode-case-'));
|
|
101
|
+
try {
|
|
102
|
+
fs.outputFileSync(path.join(probe, 'a'), '');
|
|
103
|
+
return !fs.existsSync(path.join(probe, 'A'));
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
fs.removeSync(probe);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function mergeHeader(files) {
|
|
110
|
+
const workspaces = files.map((file) => file.resolvingTeamSlug).join(', ');
|
|
111
|
+
return (`# Generated by fcode: the '${files[0].locale}' translations inherited from ${workspaces},\n` +
|
|
112
|
+
`# merged in the platform's resolution order (the first wins where they disagree).\n` +
|
|
113
|
+
`# Edit i18n/${files[0].locale}.yaml to override any of these keys.\n`);
|
|
114
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the translations a local run resolves `fcode.i18n` against, so `fcode run` behaves like
|
|
3
|
+
* the cloud does.
|
|
4
|
+
*
|
|
5
|
+
* The cloud does this work in the engine: it merges the workspace's locale over its parents', then
|
|
6
|
+
* layers the chosen locale over the primary one and ships a single flat map. Locally there is no
|
|
7
|
+
* engine, so the same layering happens here over the files in `i18n/` — including the inherited
|
|
8
|
+
* ones, which `pull` writes into the same directory as `<locale>.inherited.yaml`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function flattenTranslations(document: unknown, prefix?: string, target?: Record<string, string>): Record<string, string>;
|
|
11
|
+
/**
|
|
12
|
+
* @param requestedLocale the locale to run with, or undefined to use the workspace's primary
|
|
13
|
+
* @returns the locale actually used and its flat translations, with the primary locale underneath
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveLocalTranslations(requestedLocale?: string, workspaceDir?: string): {
|
|
16
|
+
locale?: string;
|
|
17
|
+
translations: Record<string, string>;
|
|
18
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.flattenTranslations = flattenTranslations;
|
|
4
|
+
exports.resolveLocalTranslations = resolveLocalTranslations;
|
|
5
|
+
const fs = require("fs-extra");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
const yaml_1 = require("yaml");
|
|
8
|
+
const cli_1 = require("../utils/cli");
|
|
9
|
+
const color_1 = require("@oclif/color");
|
|
10
|
+
const types_1 = require("../core/types");
|
|
11
|
+
const I18N_DIRECTORY = 'i18n';
|
|
12
|
+
const YAML_EXTENSION = '.yaml';
|
|
13
|
+
const KEY_SEPARATOR = '.';
|
|
14
|
+
/**
|
|
15
|
+
* Builds the translations a local run resolves `fcode.i18n` against, so `fcode run` behaves like
|
|
16
|
+
* the cloud does.
|
|
17
|
+
*
|
|
18
|
+
* The cloud does this work in the engine: it merges the workspace's locale over its parents', then
|
|
19
|
+
* layers the chosen locale over the primary one and ships a single flat map. Locally there is no
|
|
20
|
+
* engine, so the same layering happens here over the files in `i18n/` — including the inherited
|
|
21
|
+
* ones, which `pull` writes into the same directory as `<locale>.inherited.yaml`.
|
|
22
|
+
*/
|
|
23
|
+
function flattenTranslations(document, prefix = '', target = {}) {
|
|
24
|
+
if (document === null || document === undefined || typeof document !== 'object' || Array.isArray(document)) {
|
|
25
|
+
return target;
|
|
26
|
+
}
|
|
27
|
+
for (const [key, value] of Object.entries(document)) {
|
|
28
|
+
const keyPath = prefix ? `${prefix}${KEY_SEPARATOR}${key}` : key;
|
|
29
|
+
if (value === null || value === undefined) {
|
|
30
|
+
// A key written without a value is a placeholder: it counts as not translated, so lookups
|
|
31
|
+
// fall through to the fallback locale instead of resolving to an empty string.
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
35
|
+
flattenTranslations(value, keyPath, target);
|
|
36
|
+
}
|
|
37
|
+
else if (!Array.isArray(value)) {
|
|
38
|
+
target[keyPath] = String(value);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return target;
|
|
42
|
+
}
|
|
43
|
+
function readLocaleFile(workspaceDir, slug) {
|
|
44
|
+
const filePath = path.join(workspaceDir, I18N_DIRECTORY, `${slug}${YAML_EXTENSION}`);
|
|
45
|
+
if (!fs.pathExistsSync(filePath)) {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
return flattenTranslations((0, yaml_1.parse)(fs.readFileSync(filePath).toString()));
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
// A broken translation file must never stop a run, exactly as in the cloud.
|
|
53
|
+
cli_1.default.log(color_1.default.yellow(`\n⚠️ locale '${slug}' could not be parsed and will be ignored: ${error.message}`));
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** The identifier a locale file contributes keys to, whether the workspace owns the file or not. */
|
|
58
|
+
function identifierOf(slug) {
|
|
59
|
+
return slug.endsWith(types_1.Locale.INHERITED_SUFFIX) ? slug.slice(0, -types_1.Locale.INHERITED_SUFFIX.length) : slug;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* One locale as this workspace resolves it: what it inherits, with its own file layered on top key
|
|
63
|
+
* by key, so a key it does not translate itself still resolves to the parent's text. `pull` has
|
|
64
|
+
* already merged the parents' files into the inherited one.
|
|
65
|
+
*/
|
|
66
|
+
function readLocale(workspaceDir, locale) {
|
|
67
|
+
return {
|
|
68
|
+
...readLocaleFile(workspaceDir, `${locale}${types_1.Locale.INHERITED_SUFFIX}`),
|
|
69
|
+
...readLocaleFile(workspaceDir, locale),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function chosenPrimaryLocaleOf(workspaceDir) {
|
|
73
|
+
try {
|
|
74
|
+
const team = fs.readJsonSync(path.join(workspaceDir, 'team.json'));
|
|
75
|
+
return typeof team?.primaryLocale === 'string' && team.primaryLocale.length > 0 ? team.primaryLocale : undefined;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Every locale file in the workspace, own and inherited: `pull` writes them all to `i18n/`. */
|
|
82
|
+
function localeFileSlugs(workspaceDir) {
|
|
83
|
+
const directory = path.join(workspaceDir, I18N_DIRECTORY);
|
|
84
|
+
if (!fs.pathExistsSync(directory)) {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
return fs
|
|
88
|
+
.readdirSync(directory)
|
|
89
|
+
.filter((fileName) => fileName.endsWith(YAML_EXTENSION))
|
|
90
|
+
.map((fileName) => path.basename(fileName, YAML_EXTENSION))
|
|
91
|
+
.sort();
|
|
92
|
+
}
|
|
93
|
+
/** Every locale the workspace resolves, counting a locale defined in several workspaces once. */
|
|
94
|
+
function availableLocales(workspaceDir) {
|
|
95
|
+
return [...new Set(localeFileSlugs(workspaceDir).map((slug) => identifierOf(slug)))].sort();
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The workspace's primary locale: the one chosen in `team.json`, or its first locale when none is
|
|
99
|
+
* chosen — or when the chosen one has no file anymore, which would otherwise resolve zero keys.
|
|
100
|
+
* Mirrors the platform's `PrimaryLocale.resolve`, so a local run and the cloud agree on which
|
|
101
|
+
* locale a process without an explicit one resolves against.
|
|
102
|
+
*/
|
|
103
|
+
function primaryLocaleOf(workspaceDir) {
|
|
104
|
+
const available = availableLocales(workspaceDir);
|
|
105
|
+
const chosen = chosenPrimaryLocaleOf(workspaceDir);
|
|
106
|
+
return chosen && available.includes(chosen) ? chosen : available[0];
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* @param requestedLocale the locale to run with, or undefined to use the workspace's primary
|
|
110
|
+
* @returns the locale actually used and its flat translations, with the primary locale underneath
|
|
111
|
+
*/
|
|
112
|
+
function resolveLocalTranslations(requestedLocale, workspaceDir = process.cwd()) {
|
|
113
|
+
const primary = primaryLocaleOf(workspaceDir);
|
|
114
|
+
const locale = requestedLocale || primary;
|
|
115
|
+
if (!locale) {
|
|
116
|
+
return { locale: undefined, translations: {} };
|
|
117
|
+
}
|
|
118
|
+
const fallback = primary && primary !== locale ? readLocale(workspaceDir, primary) : {};
|
|
119
|
+
return { locale, translations: { ...fallback, ...readLocale(workspaceDir, locale) } };
|
|
120
|
+
}
|
|
@@ -34,7 +34,7 @@ function mergeVariables(base, ...overrides) {
|
|
|
34
34
|
return [...map.values()];
|
|
35
35
|
}
|
|
36
36
|
async function runProcess(options) {
|
|
37
|
-
const { slug, version, logLevel = 'DEBUG', request, jsonLogs = false, suppressElapsedLog = false } = options;
|
|
37
|
+
const { slug, version, logLevel = 'DEBUG', request, jsonLogs = false, suppressElapsedLog = false, locale } = options;
|
|
38
38
|
const processRepository = new process_1.default();
|
|
39
39
|
const variableRepository = new variable_1.default();
|
|
40
40
|
const fcodeProcess = await processRepository.find(slug, version);
|
|
@@ -50,7 +50,7 @@ async function runProcess(options) {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
const ExecutionTask = fcodeProcess.script.programmingLanguage === 'PYTHON' ? python_execution_task_1.default : javascript_execution_task_1.default;
|
|
53
|
-
const executor = new ExecutionTask(fcodeProcess.script.programmingLanguage, fcodeProcess, { version, parameters, variables, logLevel, request, jsonLogs });
|
|
53
|
+
const executor = new ExecutionTask(fcodeProcess.script.programmingLanguage, fcodeProcess, { version, parameters, variables, logLevel, request, jsonLogs, locale });
|
|
54
54
|
const start = Date.now();
|
|
55
55
|
try {
|
|
56
56
|
await executor.prepare();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import FcodeGraphQLClient from './fcode-graphql-client';
|
|
2
|
+
import GlobalConfig from '../config/global';
|
|
3
|
+
declare const TeamAccessService: {
|
|
4
|
+
globalConfig: GlobalConfig;
|
|
5
|
+
clientFactory: (options: {
|
|
6
|
+
url: string;
|
|
7
|
+
accessToken: string;
|
|
8
|
+
teamSlug: string;
|
|
9
|
+
}) => FcodeGraphQLClient;
|
|
10
|
+
ensureTeamAccess(url: string, slug: string): Promise<void>;
|
|
11
|
+
checkAccess(url: string, slug: string): Promise<void>;
|
|
12
|
+
};
|
|
13
|
+
export default TeamAccessService;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const auth_service_1 = require("./auth-service");
|
|
4
|
+
const fcode_graphql_client_1 = require("./fcode-graphql-client");
|
|
5
|
+
const global_1 = require("../config/global");
|
|
6
|
+
const errors_1 = require("../core/errors");
|
|
7
|
+
function is403(error) {
|
|
8
|
+
return error?.response?.status === 403;
|
|
9
|
+
}
|
|
10
|
+
const TeamAccessService = {
|
|
11
|
+
globalConfig: new global_1.default(),
|
|
12
|
+
clientFactory: (options) => new fcode_graphql_client_1.default(options),
|
|
13
|
+
// Pre-flight check that the current session can access the team, run before any
|
|
14
|
+
// local state is touched so a denied session fails once with a clear error
|
|
15
|
+
// instead of erroring on every resource pull. A 403 may just mean the token
|
|
16
|
+
// predates the team: groups are snapshotted into the token at issuance
|
|
17
|
+
// (FCT-61231), so re-mint it through the refresh grant and retry once.
|
|
18
|
+
async ensureTeamAccess(url, slug) {
|
|
19
|
+
try {
|
|
20
|
+
await this.checkAccess(url, slug);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (!is403(error)) {
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
if (!(await auth_service_1.default.refreshSession(url))) {
|
|
27
|
+
throw new errors_1.TeamAccessDenied(slug);
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
await this.checkAccess(url, slug);
|
|
31
|
+
}
|
|
32
|
+
catch (retryError) {
|
|
33
|
+
if (is403(retryError)) {
|
|
34
|
+
throw new errors_1.TeamAccessDenied(slug);
|
|
35
|
+
}
|
|
36
|
+
throw retryError;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
async checkAccess(url, slug) {
|
|
41
|
+
const { token } = await this.globalConfig.getAuthInfo(url);
|
|
42
|
+
await this.clientFactory({ url, accessToken: token.access_token, teamSlug: slug }).verifyTeamAccess();
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
exports.default = TeamAccessService;
|
|
@@ -14,6 +14,6 @@ export declare function renderGitignoreWithInherited(existing: string, inherited
|
|
|
14
14
|
* parent team and pulled read-only) are not tracked by git. Regenerated from the
|
|
15
15
|
* current workspace metadata, so resources that stop being inherited drop out.
|
|
16
16
|
* Inherited variable values live in one file, which is ignored as soon as the
|
|
17
|
-
* workspace inherits any variable.
|
|
17
|
+
* workspace inherits any variable; inherited locales are one file each, like modules.
|
|
18
18
|
*/
|
|
19
19
|
export declare function syncInheritedGitignore(workspaceDir?: string): Promise<void>;
|
package/dist/utils/gitignore.js
CHANGED
|
@@ -46,7 +46,7 @@ function stripManagedBlock(content) {
|
|
|
46
46
|
* parent team and pulled read-only) are not tracked by git. Regenerated from the
|
|
47
47
|
* current workspace metadata, so resources that stop being inherited drop out.
|
|
48
48
|
* Inherited variable values live in one file, which is ignored as soon as the
|
|
49
|
-
* workspace inherits any variable.
|
|
49
|
+
* workspace inherits any variable; inherited locales are one file each, like modules.
|
|
50
50
|
*/
|
|
51
51
|
async function syncInheritedGitignore(workspaceDir = process.cwd()) {
|
|
52
52
|
const team = await new workspace_1.default(workspaceDir).getActiveTeam();
|
|
@@ -59,6 +59,9 @@ async function syncInheritedGitignore(workspaceDir = process.cwd()) {
|
|
|
59
59
|
.filter((module) => module.resolvingTeamSlug)
|
|
60
60
|
.map((module) => `modules/${(0, filenamify_1.default)(module.name)}/`),
|
|
61
61
|
...(Object.values(team.variables ?? {}).some((variable) => variable.resolvingTeamSlug) ? [inheritedVariablesFileName] : []),
|
|
62
|
+
...Object.values(team.i18n ?? {})
|
|
63
|
+
.filter((locale) => locale.resolvingTeamSlug)
|
|
64
|
+
.map((locale) => `i18n/${(0, filenamify_1.default)(locale.slug)}.yaml`),
|
|
62
65
|
];
|
|
63
66
|
const gitignorePath = path.join(workspaceDir, '.gitignore');
|
|
64
67
|
const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf8') : '';
|