@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.
Files changed (60) hide show
  1. package/README.md +128 -2
  2. package/dist/commands/add.js +3 -1
  3. package/dist/commands/clone.d.ts +1 -0
  4. package/dist/commands/clone.js +19 -1
  5. package/dist/commands/i18n/add.d.ts +9 -0
  6. package/dist/commands/i18n/add.js +13 -0
  7. package/dist/commands/i18n/pull.d.ts +12 -0
  8. package/dist/commands/i18n/pull.js +14 -0
  9. package/dist/commands/i18n/push.d.ts +12 -0
  10. package/dist/commands/i18n/push.js +14 -0
  11. package/dist/commands/i18n/remove.d.ts +9 -0
  12. package/dist/commands/i18n/remove.js +19 -0
  13. package/dist/commands/i18n/reset.d.ts +9 -0
  14. package/dist/commands/i18n/reset.js +13 -0
  15. package/dist/commands/i18n/status.d.ts +11 -0
  16. package/dist/commands/i18n/status.js +42 -0
  17. package/dist/commands/pull.js +5 -3
  18. package/dist/commands/push.js +5 -3
  19. package/dist/commands/reset.js +3 -1
  20. package/dist/commands/run.d.ts +1 -0
  21. package/dist/commands/run.js +5 -2
  22. package/dist/commands/status.js +4 -2
  23. package/dist/commands/team/push.js +3 -0
  24. package/dist/config/remote.js +1 -1
  25. package/dist/config/workspace.js +10 -0
  26. package/dist/core/commands/api-client-command.d.ts +1 -0
  27. package/dist/core/commands/api-client-command.js +5 -0
  28. package/dist/core/commands/base-command.d.ts +2 -0
  29. package/dist/core/commands/base-command.js +31 -0
  30. package/dist/core/commands/i18n-command.d.ts +5 -0
  31. package/dist/core/commands/i18n-command.js +16 -0
  32. package/dist/core/errors.d.ts +10 -0
  33. package/dist/core/errors.js +34 -1
  34. package/dist/core/executors/execution-task.js +4 -0
  35. package/dist/core/executors/javascript-execution-task.js +26 -1
  36. package/dist/core/resource-pusher.js +15 -0
  37. package/dist/core/types.d.ts +29 -1
  38. package/dist/core/types.js +46 -3
  39. package/dist/repositories/locale.d.ts +24 -0
  40. package/dist/repositories/locale.js +102 -0
  41. package/dist/repositories/resource.js +4 -1
  42. package/dist/repositories/team.js +5 -2
  43. package/dist/runtime/libs/python/fcode/__init__.py +1 -0
  44. package/dist/runtime/libs/python/fcode/context.py +43 -1
  45. package/dist/services/auth-service.d.ts +1 -0
  46. package/dist/services/auth-service.js +12 -0
  47. package/dist/services/fcode-graphql-client.d.ts +13 -1
  48. package/dist/services/fcode-graphql-client.js +136 -0
  49. package/dist/services/locale-merger.d.ts +23 -0
  50. package/dist/services/locale-merger.js +114 -0
  51. package/dist/services/locale-resolver.d.ts +18 -0
  52. package/dist/services/locale-resolver.js +120 -0
  53. package/dist/services/process-runner.d.ts +1 -0
  54. package/dist/services/process-runner.js +2 -2
  55. package/dist/services/team-access.d.ts +13 -0
  56. package/dist/services/team-access.js +45 -0
  57. package/dist/utils/gitignore.d.ts +1 -1
  58. package/dist/utils/gitignore.js +4 -1
  59. package/oclif.manifest.json +194 -1
  60. package/package.json +2 -1
@@ -4,16 +4,42 @@ const core_1 = require("@oclif/core");
4
4
  const global_1 = require("../../config/global");
5
5
  const workspace_1 = require("../../config/workspace");
6
6
  const color_1 = require("@oclif/color");
7
+ const auth_service_1 = require("../../services/auth-service");
7
8
  const errors_1 = require("../errors");
8
9
  const cli_1 = require("../../utils/cli");
9
10
  const url_utils_1 = require("../../utils/url-utils");
10
11
  class BaseCommand extends core_1.Command {
12
+ constructor() {
13
+ super(...arguments);
14
+ this.sessionRefreshRetried = false;
15
+ }
11
16
  async catch(error) {
12
17
  // do any extra work with error
13
18
  if (error && error.response && error.response.status === 401) {
19
+ process.exitCode = 1;
14
20
  cli_1.default.error(`User authentication is invalid for current team.`);
15
21
  return;
16
22
  }
23
+ if (error && error.response && error.response.status === 403) {
24
+ let origin;
25
+ try {
26
+ origin = await this.workspaceConfig.getTeamOrigin();
27
+ }
28
+ catch {
29
+ // No workspace context; report without the slug.
30
+ }
31
+ // A 403 with a valid session usually means the token's team groups went
32
+ // stale — they are snapshotted at issuance (FCT-61231). Re-mint the token
33
+ // and retry the command once; a second denial falls through to the error.
34
+ if (!this.sessionRefreshRetried && origin && (await auth_service_1.default.refreshSession(origin.url))) {
35
+ this.sessionRefreshRetried = true;
36
+ await this.onSessionRefreshed();
37
+ return this.run().catch((retryError) => this.catch(retryError));
38
+ }
39
+ process.exitCode = 1;
40
+ cli_1.default.error((0, errors_1.teamAccessDeniedMessage)(origin?.slug));
41
+ return;
42
+ }
17
43
  if (error instanceof errors_1.NotAuthenticated) {
18
44
  let errorMessage = 'No session active. Logging you in';
19
45
  const loginArgs = [];
@@ -40,9 +66,11 @@ class BaseCommand extends core_1.Command {
40
66
  return this.run();
41
67
  }
42
68
  if (error instanceof errors_1.CLIError) {
69
+ process.exitCode = 1;
43
70
  cli_1.default.error(error);
44
71
  return;
45
72
  }
73
+ process.exitCode = 1;
46
74
  cli_1.default.error(error.message);
47
75
  }
48
76
  async init(url) {
@@ -53,6 +81,9 @@ class BaseCommand extends core_1.Command {
53
81
  const { authUrl } = await this.globalConfig.getAuthInfo(workspaceUrl);
54
82
  await this.config.runHook('auth', { argv: [workspaceUrl, authUrl], id: 'init' });
55
83
  }
84
+ // Called after a 403-triggered session refresh, before the retry: rewire
85
+ // anything that captured the old access token. API commands override this.
86
+ async onSessionRefreshed() { }
56
87
  async logActiveTeam() {
57
88
  const { slug, url } = await this.workspaceConfig.getTeamOrigin();
58
89
  cli_1.default.log(color_1.default.magenta(`💻 Working on Factorial Code team '${slug}${url !== global_1.FCODE_CLOUD_URL ? ` (${url})` : ''}'.`));
@@ -0,0 +1,5 @@
1
+ import { Locale } from '../types';
2
+ import ResourceCommand from './resource-command';
3
+ export default abstract class I18nCommand extends ResourceCommand<Locale> {
4
+ init(): Promise<void>;
5
+ }
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const locale_1 = require("../../repositories/locale");
4
+ const resource_merger_1 = require("../resource-merger");
5
+ const resource_pusher_1 = require("../resource-pusher");
6
+ const resource_command_1 = require("./resource-command");
7
+ class I18nCommand extends resource_command_1.default {
8
+ async init() {
9
+ await super.init();
10
+ this._resourceRepository = new locale_1.default();
11
+ this._resourceMerger = new resource_merger_1.default(this._resourceRepository);
12
+ this._resourcePusher = new resource_pusher_1.default(this._resourceRepository, this._fcodeGraphQLClient);
13
+ this._resourceQuery = () => this._fcodeGraphQLClient.allLocales.bind(this._fcodeGraphQLClient)();
14
+ }
15
+ }
16
+ exports.default = I18nCommand;
@@ -30,6 +30,12 @@ export declare class LocalVariableNotFound extends ResourceNotFound {
30
30
  export declare class CloudVariableNotFound extends ResourceNotFound {
31
31
  constructor(error: Error | string, variableId: string);
32
32
  }
33
+ export declare class LocalLocaleNotFound extends ResourceNotFound {
34
+ constructor(error: Error | string, locale: string);
35
+ }
36
+ export declare class CloudLocaleNotFound extends ResourceNotFound {
37
+ constructor(error: Error | string, locale: string);
38
+ }
33
39
  export declare class LocalProgrammingLanguageManifestNotFound extends ResourceNotFound {
34
40
  constructor(error: Error | string, programmingLanguageManifestId: string);
35
41
  }
@@ -41,6 +47,10 @@ export declare class NotAuthenticated extends CLIError {
41
47
  constructor(url: string);
42
48
  getUrl(): string;
43
49
  }
50
+ export declare function teamAccessDeniedMessage(slug?: string): string;
51
+ export declare class TeamAccessDenied extends CLIError {
52
+ constructor(slug?: string);
53
+ }
44
54
  export declare class NotFcodeWorkspace extends CLIError {
45
55
  constructor();
46
56
  }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ErrorExecutingProcess = exports.ProcessDependenciesNotInstalled = exports.DependenciesNotInstalled = exports.NotFcodeWorkspace = exports.NotAuthenticated = exports.CloudProgrammingLanguageManifestNotFound = exports.LocalProgrammingLanguageManifestNotFound = exports.CloudVariableNotFound = exports.LocalVariableNotFound = exports.CloudScriptLibraryNotFound = exports.LocalScriptLibraryNotFound = exports.CloudProcessNotFound = exports.LocalProcessNotFound = exports.ResourceNotFound = exports.CLIError = void 0;
3
+ exports.ErrorExecutingProcess = exports.ProcessDependenciesNotInstalled = exports.DependenciesNotInstalled = exports.NotFcodeWorkspace = exports.TeamAccessDenied = exports.NotAuthenticated = exports.CloudProgrammingLanguageManifestNotFound = exports.LocalProgrammingLanguageManifestNotFound = exports.CloudLocaleNotFound = exports.LocalLocaleNotFound = exports.CloudVariableNotFound = exports.LocalVariableNotFound = exports.CloudScriptLibraryNotFound = exports.LocalScriptLibraryNotFound = exports.CloudProcessNotFound = exports.LocalProcessNotFound = exports.ResourceNotFound = exports.CLIError = void 0;
4
+ exports.teamAccessDeniedMessage = teamAccessDeniedMessage;
4
5
  const global_1 = require("../config/global");
5
6
  class CLIError extends Error {
6
7
  constructor(message, options) {
@@ -74,6 +75,24 @@ class CloudVariableNotFound extends ResourceNotFound {
74
75
  }
75
76
  }
76
77
  exports.CloudVariableNotFound = CloudVariableNotFound;
78
+ class LocalLocaleNotFound extends ResourceNotFound {
79
+ constructor(error, locale) {
80
+ super(error, 'Locale', locale, {
81
+ code: 'ERR_LOCAL_LOCALE_NOT_FOUND',
82
+ suggestions: [],
83
+ });
84
+ }
85
+ }
86
+ exports.LocalLocaleNotFound = LocalLocaleNotFound;
87
+ class CloudLocaleNotFound extends ResourceNotFound {
88
+ constructor(error, locale) {
89
+ super(error, 'Locale', locale, {
90
+ code: 'ERR_CLOUD_LOCALE_NOT_FOUND',
91
+ suggestions: [],
92
+ });
93
+ }
94
+ }
95
+ exports.CloudLocaleNotFound = CloudLocaleNotFound;
77
96
  class LocalProgrammingLanguageManifestNotFound extends ResourceNotFound {
78
97
  constructor(error, programmingLanguageManifestId) {
79
98
  super(error, 'ProgrammingLanguageManifest', programmingLanguageManifestId, {
@@ -102,6 +121,20 @@ class NotAuthenticated extends CLIError {
102
121
  }
103
122
  }
104
123
  exports.NotAuthenticated = NotAuthenticated;
124
+ function teamAccessDeniedMessage(slug) {
125
+ const team = slug ? ` '${slug}'` : '';
126
+ return (`You don't have access to team${team} with your current session.` +
127
+ ` If you were granted access recently, run 'fcode logout' and 'fcode login' and retry.`);
128
+ }
129
+ class TeamAccessDenied extends CLIError {
130
+ constructor(slug) {
131
+ super(teamAccessDeniedMessage(slug), {
132
+ code: 'ERR_TEAM_ACCESS_DENIED',
133
+ exit: 1,
134
+ });
135
+ }
136
+ }
137
+ exports.TeamAccessDenied = TeamAccessDenied;
105
138
  class NotFcodeWorkspace extends CLIError {
106
139
  constructor() {
107
140
  super('⚠️ Current directory is not a Factorial Code workspace.', {
@@ -4,6 +4,7 @@ const node_child_process_1 = require("node:child_process");
4
4
  const fs = require("node:fs");
5
5
  const os = require("node:os");
6
6
  const path = require("node:path");
7
+ const locale_resolver_1 = require("../../services/locale-resolver");
7
8
  const errors_1 = require("../errors");
8
9
  const cli_1 = require("../../utils/cli");
9
10
  const dependencies_1 = require("../utils/dependencies");
@@ -114,6 +115,9 @@ class ExecutionTask {
114
115
  },
115
116
  variables: this.getVariablesObject(),
116
117
  request: this._options.request ?? 'No request, you are using the CLI',
118
+ // Layered here rather than in the generated runtime: the cloud ships one already-merged
119
+ // flat map, and a local run has to resolve fcode.i18n against the same shape.
120
+ i18n: (0, locale_resolver_1.resolveLocalTranslations)(this._options.locale),
117
121
  };
118
122
  }
119
123
  async prepare() {
@@ -111,7 +111,12 @@ const main = typeof _hooksModule['${fn}'] === 'function'
111
111
  };`}
112
112
 
113
113
  global.fcode = {
114
- import: (moduleName, version) => {
114
+ // A version is either a bare string or an options object ({ version: 'v1' }), the
115
+ // form every other versioned helper takes — the cloud reads both, so a local run must too.
116
+ import: (moduleName, tagOrOptions) => {
117
+ const version = typeof tagOrOptions === 'string'
118
+ ? tagOrOptions
119
+ : (tagOrOptions && typeof tagOrOptions === 'object' ? tagOrOptions.version : undefined);
115
120
  let modulePath = require('path').join('${process.cwd()}', 'modules', moduleName);
116
121
  if(version) {
117
122
  modulePath = require('path').join(modulePath, 'versions', version)
@@ -588,6 +593,26 @@ const main = typeof _hooksModule['${fn}'] === 'function'
588
593
  }
589
594
  };
590
595
  })(),
596
+ // The third argument is the version options the cloud honours. A workspace directory holds
597
+ // no published snapshots, so a pinned call resolves against the files on disk — the same
598
+ // per-file fallback the cloud applies to a locale that was never published at that tag.
599
+ // Own properties only and no array arguments, exactly like the cloud executors.
600
+ i18n: Object.assign((key, args, _options) => {
601
+ const translations = ${JSON.stringify(metadata.i18n.translations, null, 2)};
602
+ const template = Object.hasOwn(translations, key) ? translations[key] : undefined;
603
+ if (template === undefined) {
604
+ return key;
605
+ }
606
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
607
+ return template;
608
+ }
609
+ // Single pass, so a substituted value containing %{...} is never rescanned.
610
+ return template.replace(/%\{([^}]*)\}/g, (match, name) => {
611
+ const trimmed = name.trim();
612
+ const value = Object.hasOwn(args, trimmed) ? args[trimmed] : undefined;
613
+ return value === undefined || value === null ? match : String(value);
614
+ });
615
+ }, { locale: ${JSON.stringify(metadata.i18n.locale ?? null)} }),
591
616
  sendMail: async ({ to, subject, text, html } = {}) => {
592
617
  // No manager locally: log the email contents instead of sending, so
593
618
  // devs can see exactly what would be delivered when run in the cloud.
@@ -82,6 +82,16 @@ class ResourcePusher {
82
82
  const createdVariable = await this._fcodeGraphQLClient.pushVariable(variable);
83
83
  return this._repository.save(createdVariable);
84
84
  }
85
+ if (this._repository._repositoryName === 'i18n') {
86
+ const locale = resource;
87
+ // The recorded id is only a hint: pushing an identifier a parent workspace owns has to
88
+ // create an override here, so the update path is taken only for a locale this workspace
89
+ // already owns.
90
+ const pushed = locale.id && !locale.resolvingTeamSlug
91
+ ? await this._fcodeGraphQLClient.updateLocale(locale)
92
+ : await this._fcodeGraphQLClient.pushLocale(locale);
93
+ return this._repository.save(pushed);
94
+ }
85
95
  if (this._repository._repositoryName === 'dependencies') {
86
96
  const updatedProgrammingLanguageManifests = await this._fcodeGraphQLClient.pushProgrammingLanguageManifest(resource);
87
97
  return this._repository.save(updatedProgrammingLanguageManifests);
@@ -114,6 +124,11 @@ class ResourcePusher {
114
124
  if (this._repository._repositoryName === 'variables') {
115
125
  await this._fcodeGraphQLClient.removeVariable(resource.id);
116
126
  }
127
+ if (this._repository._repositoryName === 'i18n') {
128
+ // By identifier, not slug: an inherited locale is tracked under a qualified slug, and only
129
+ // this workspace's own locales — where the two coincide — ever reach a remote delete.
130
+ await this._fcodeGraphQLClient.removeLocale(resource.locale);
131
+ }
117
132
  this._repository.removeMetadata(resource.slug);
118
133
  }
119
134
  catch (error) {
@@ -41,6 +41,9 @@ export interface TeamMetadata {
41
41
  variables: {
42
42
  [key: string]: Variable;
43
43
  };
44
+ i18n: {
45
+ [key: string]: Locale;
46
+ };
44
47
  dependencies: {
45
48
  [key: string]: ProgrammingLanguageManifest;
46
49
  };
@@ -187,6 +190,27 @@ export declare class ScriptLibrary extends Resource {
187
190
  constructor(id: string, name: string, sourceCode: string, programmingLanguage: ProgrammingLanguage, resolvingTeamSlug?: string);
188
191
  get contentHash(): string;
189
192
  }
193
+ export declare class Locale extends Resource {
194
+ static LOCALE_REGEX: RegExp;
195
+ static INHERITED_SUFFIX: string;
196
+ id: string | undefined;
197
+ slug: string;
198
+ locale: string;
199
+ content: string;
200
+ constructor(id: string | undefined, locale: string, content: string, resolvingTeamSlug?: string);
201
+ /**
202
+ * The name a locale is tracked and stored under. Unlike every other resource, the identifier is
203
+ * not unique: a workspace sees its own `en` and each parent workspace's `en`, and all of them are
204
+ * live, because an override is layered over the parent's file key by key rather than replacing
205
+ * it. Naming files after the identifier alone would keep only one of them.
206
+ *
207
+ * A workspace directory therefore holds two files at most per identifier: `en.yaml` for its own
208
+ * and `en.inherited.yaml` for everything it inherits, which `mergeInheritedLocales` collapses
209
+ * into one file. An identifier cannot contain a dot, so the name parses back.
210
+ */
211
+ static slugFor(locale: string, resolvingTeamSlug?: string): string;
212
+ get contentHash(): string;
213
+ }
190
214
  export declare class VersionedScriptLibrary extends Resource {
191
215
  id: string;
192
216
  slug: string;
@@ -262,9 +286,10 @@ export declare class Team extends Resource {
262
286
  errorHandlerConfig?: ErrorHandlerConfig;
263
287
  parentTeamSlugs: string[];
264
288
  webhookAuth?: WebhookAuth;
289
+ primaryLocale?: string;
265
290
  versions?: TeamVersionFileEntry[];
266
291
  aliases?: TeamAliasFileEntry[];
267
- constructor(slug: string, parentTeamSlugs: string[], zoneId?: string, errorHandlerConfig?: ErrorHandlerConfig, webhookAuth?: WebhookAuth);
292
+ constructor(slug: string, parentTeamSlugs: string[], zoneId?: string, errorHandlerConfig?: ErrorHandlerConfig, webhookAuth?: WebhookAuth, primaryLocale?: string);
268
293
  get contentHash(): string;
269
294
  }
270
295
  export interface RawErrorHandlerConfig {
@@ -277,6 +302,7 @@ export interface RawTeamSettings {
277
302
  errorHandlerConfig?: RawErrorHandlerConfig | null;
278
303
  parentTeamSlugs: string[];
279
304
  webhookAuth?: WebhookAuth | null;
305
+ primaryLocale?: string | null;
280
306
  }
281
307
  export interface TeamUpdateInput {
282
308
  zoneId?: string;
@@ -286,6 +312,7 @@ export interface TeamUpdateInput {
286
312
  headerName?: string | null;
287
313
  variableKey?: string | null;
288
314
  } | null;
315
+ primaryLocale?: string;
289
316
  }
290
317
  export type ResourceMetadata = {
291
318
  [key: string]: any;
@@ -317,6 +344,7 @@ export type ExecutionOptions = {
317
344
  hookFilePath?: string;
318
345
  hookFunction?: string;
319
346
  silent?: boolean;
347
+ locale?: string;
320
348
  };
321
349
  export declare const ForceFlag: {
322
350
  force: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ServiceAccount = exports.Dependency = exports.DependenciesFlags = exports.TeamNameArg = exports.ResourceSlugArg = exports.ForceFlag = exports.ScriptLibraryNotFound = exports.ProcessNotFound = exports.Team = exports.ProgrammingLanguageManifest = exports.Variable = exports.VersionedScriptLibrary = exports.ScriptLibrary = exports.VersionedProcess = exports.Process = exports.DEFAULT_WEBHOOK_AUTH_HEADER = exports.programmingLanguageDisplayName = exports.ResourceStatus = exports.OnConflictResolution = exports.Resource = void 0;
3
+ exports.ServiceAccount = exports.Dependency = exports.DependenciesFlags = exports.TeamNameArg = exports.ResourceSlugArg = exports.ForceFlag = exports.ScriptLibraryNotFound = exports.ProcessNotFound = exports.Team = exports.ProgrammingLanguageManifest = exports.Variable = exports.VersionedScriptLibrary = exports.Locale = exports.ScriptLibrary = exports.VersionedProcess = exports.Process = exports.DEFAULT_WEBHOOK_AUTH_HEADER = exports.programmingLanguageDisplayName = exports.ResourceStatus = exports.OnConflictResolution = exports.Resource = void 0;
4
4
  const core_1 = require("@oclif/core");
5
5
  const crypto = require("node:crypto");
6
6
  const canonical_hash_1 = require("../utils/canonical-hash");
@@ -103,6 +103,41 @@ class ScriptLibrary extends Resource {
103
103
  }
104
104
  }
105
105
  exports.ScriptLibrary = ScriptLibrary;
106
+ class Locale extends Resource {
107
+ constructor(id, locale, content, resolvingTeamSlug) {
108
+ super();
109
+ this.id = id;
110
+ this.slug = Locale.slugFor(locale, resolvingTeamSlug);
111
+ this.locale = locale;
112
+ this.content = content;
113
+ this.resolvingTeamSlug = resolvingTeamSlug;
114
+ }
115
+ /**
116
+ * The name a locale is tracked and stored under. Unlike every other resource, the identifier is
117
+ * not unique: a workspace sees its own `en` and each parent workspace's `en`, and all of them are
118
+ * live, because an override is layered over the parent's file key by key rather than replacing
119
+ * it. Naming files after the identifier alone would keep only one of them.
120
+ *
121
+ * A workspace directory therefore holds two files at most per identifier: `en.yaml` for its own
122
+ * and `en.inherited.yaml` for everything it inherits, which `mergeInheritedLocales` collapses
123
+ * into one file. An identifier cannot contain a dot, so the name parses back.
124
+ */
125
+ static slugFor(locale, resolvingTeamSlug) {
126
+ return resolvingTeamSlug ? `${locale}${Locale.INHERITED_SUFFIX}` : locale;
127
+ }
128
+ // Hashed over the raw YAML: the file is the source of truth and round-trips comments and
129
+ // ordering, so any edit — even one that parses to the same translations — is a change to push.
130
+ get contentHash() {
131
+ return crypto.createHash('md5').update(this.content).digest('hex');
132
+ }
133
+ }
134
+ exports.Locale = Locale;
135
+ // Free-form identifier rather than a validated BCP-47 tag, so a workspace can use whatever it
136
+ // already names its translations after. Case is significant: pt-BR and pt-br are different
137
+ // locales, and the platform's unique index is case-sensitive too.
138
+ Locale.LOCALE_REGEX = /^[A-Za-z0-9][\w-]{0,19}$/;
139
+ // Marks a file as owned by a parent workspace, the way `variables.inherited.env` does.
140
+ Locale.INHERITED_SUFFIX = '.inherited';
106
141
  class VersionedScriptLibrary extends Resource {
107
142
  constructor(id, tag, sourceCode, programmingLanguage) {
108
143
  super();
@@ -166,19 +201,22 @@ class ProgrammingLanguageManifest extends Resource {
166
201
  }
167
202
  exports.ProgrammingLanguageManifest = ProgrammingLanguageManifest;
168
203
  class Team extends Resource {
169
- constructor(slug, parentTeamSlugs, zoneId, errorHandlerConfig, webhookAuth) {
204
+ constructor(slug, parentTeamSlugs, zoneId, errorHandlerConfig, webhookAuth, primaryLocale) {
170
205
  super();
171
206
  this.slug = slug;
172
207
  this.parentTeamSlugs = parentTeamSlugs;
173
208
  this.zoneId = zoneId;
174
209
  this.errorHandlerConfig = errorHandlerConfig;
175
210
  this.webhookAuth = webhookAuth;
211
+ this.primaryLocale = primaryLocale;
176
212
  }
177
213
  get contentHash() {
178
214
  const payload = {
179
215
  zoneId: this.zoneId || null,
180
216
  errorHandlerConfig: this.errorHandlerConfig || null,
181
- parentTeamSlugs: [...(this.parentTeamSlugs || [])].sort(),
217
+ // Kept in the given order, not sorted: the order is the inheritance precedence, so a pure
218
+ // reorder is a real change that must be detected and pushed.
219
+ parentTeamSlugs: [...(this.parentTeamSlugs || [])],
182
220
  };
183
221
  // Inserted only when set, so every team.json that predates webhook auth keeps the
184
222
  // hash already stored in .fcode and does not report a phantom conflict. A key present
@@ -186,6 +224,11 @@ class Team extends Resource {
186
224
  if (this.webhookAuth) {
187
225
  payload.webhookAuth = this.webhookAuth;
188
226
  }
227
+ // Same insert-only-when-set rule, for the same reason: a team.json written before locales
228
+ // existed must keep the hash already stored in .fcode.
229
+ if (this.primaryLocale) {
230
+ payload.primaryLocale = this.primaryLocale;
231
+ }
189
232
  return (0, canonical_hash_1.canonicalJsonHash)(payload);
190
233
  }
191
234
  }
@@ -0,0 +1,24 @@
1
+ import { Locale, ResourceMetadata } from '../core/types';
2
+ import ResourceRepositoryBase from './resource';
3
+ /**
4
+ * Locales live as one YAML file per locale directly under `i18n/`, named after the slug
5
+ * (`i18n/pt-BR.yaml`, `i18n/pt-BR.inherited.yaml`). A file, not a directory: a locale has exactly
6
+ * one artifact, and naming the file after the identifier is what lets `push` work without knowing
7
+ * any ids.
8
+ *
9
+ * A workspace keeps what it inherits next to what it owns, because overriding a locale only layers
10
+ * keys over the inherited file rather than replacing it — see {@link Locale.slugFor}.
11
+ */
12
+ declare class LocaleRepository extends ResourceRepositoryBase<Locale> {
13
+ constructor();
14
+ find(slug: string): Promise<Locale>;
15
+ findSlugsNotAdded(): Promise<Array<string>>;
16
+ protected getResourcePath(locale: Locale): string;
17
+ protected removeFiles(locale: Locale): Promise<void>;
18
+ protected resourceToMetadata(locale: Locale): ResourceMetadata;
19
+ protected saveFiles(locale: Locale): Promise<void>;
20
+ metadataFromFiles(slug: string): Promise<ResourceMetadata>;
21
+ validSlug(slug: string): boolean;
22
+ slugFormatMessage(): string;
23
+ }
24
+ export default LocaleRepository;
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const fs = require("fs-extra");
4
+ const path = require("node:path");
5
+ const color_1 = require("@oclif/color");
6
+ const cli_1 = require("../utils/cli");
7
+ const errors_1 = require("../core/errors");
8
+ const types_1 = require("../core/types");
9
+ const file_utils_1 = require("../utils/file-utils");
10
+ const resource_1 = require("./resource");
11
+ const YAML_EXTENSION = '.yaml';
12
+ /**
13
+ * Locales live as one YAML file per locale directly under `i18n/`, named after the slug
14
+ * (`i18n/pt-BR.yaml`, `i18n/pt-BR.inherited.yaml`). A file, not a directory: a locale has exactly
15
+ * one artifact, and naming the file after the identifier is what lets `push` work without knowing
16
+ * any ids.
17
+ *
18
+ * A workspace keeps what it inherits next to what it owns, because overriding a locale only layers
19
+ * keys over the inherited file rather than replacing it — see {@link Locale.slugFor}.
20
+ */
21
+ class LocaleRepository extends resource_1.default {
22
+ constructor() {
23
+ super({
24
+ name: 'i18n',
25
+ });
26
+ }
27
+ async find(slug) {
28
+ const { [this._repositoryName]: locales } = await this.workspaceConfig.getActiveTeam();
29
+ const localeMetadata = locales?.[slug];
30
+ if (!localeMetadata) {
31
+ throw new errors_1.LocalLocaleNotFound(new Error('Locale not found on team metadata'), slug);
32
+ }
33
+ try {
34
+ const content = fs.readFileSync(this.getResourcePath(localeMetadata)).toString();
35
+ return new types_1.Locale(localeMetadata.id, localeMetadata.locale ?? slug, content, localeMetadata.resolvingTeamSlug);
36
+ }
37
+ catch (error) {
38
+ throw new errors_1.LocalLocaleNotFound(error, slug);
39
+ }
40
+ }
41
+ async findSlugsNotAdded() {
42
+ const slugsNotAdded = [];
43
+ if (!fs.pathExistsSync(this._repositoryName)) {
44
+ return slugsNotAdded;
45
+ }
46
+ for (const fileName of fs.readdirSync(this._repositoryName)) {
47
+ if (!fileName.endsWith(YAML_EXTENSION)) {
48
+ continue;
49
+ }
50
+ const slug = path.basename(fileName, YAML_EXTENSION);
51
+ if (await this.workspaceConfig.exists('i18n', slug)) {
52
+ continue;
53
+ }
54
+ // Only `pull` writes an inherited file, and it registers it as it does. One left behind by a
55
+ // workspace that no longer inherits it is stale, not a locale waiting to be tracked.
56
+ if (slug.endsWith(types_1.Locale.INHERITED_SUFFIX)) {
57
+ continue;
58
+ }
59
+ if (this.validSlug(slug)) {
60
+ slugsNotAdded.push(slug);
61
+ }
62
+ else {
63
+ cli_1.default.log(color_1.default.yellow(`\n⚠️ local locale file '${fileName}' doesn't have a valid name and will be ignored. ${this.slugFormatMessage()}`));
64
+ }
65
+ }
66
+ return slugsNotAdded;
67
+ }
68
+ getResourcePath(locale) {
69
+ return path.join(this._repositoryName, `${locale.slug ?? locale.locale}${YAML_EXTENSION}`);
70
+ }
71
+ async removeFiles(locale) {
72
+ fs.removeSync(this.getResourcePath(locale));
73
+ this.removeDirectoryIfEmpty(this._repositoryName);
74
+ }
75
+ resourceToMetadata(locale) {
76
+ return {
77
+ id: locale.id,
78
+ locale: locale.locale,
79
+ slug: locale.slug,
80
+ ...(locale.resolvingTeamSlug && { resolvingTeamSlug: locale.resolvingTeamSlug }),
81
+ };
82
+ }
83
+ async saveFiles(locale) {
84
+ // Inherited locales are written read-only, like inherited modules: they belong to a parent
85
+ // workspace, and editing one here would push an override rather than change the parent.
86
+ (0, file_utils_1.outputFileSyncIfChanged)(this.getResourcePath(locale), locale.content || '', { readOnly: Boolean(locale.resolvingTeamSlug) });
87
+ }
88
+ async metadataFromFiles(slug) {
89
+ return {
90
+ locale: slug,
91
+ slug: slug,
92
+ contentHash: '',
93
+ };
94
+ }
95
+ validSlug(slug) {
96
+ return types_1.Locale.LOCALE_REGEX.test(slug);
97
+ }
98
+ slugFormatMessage() {
99
+ return "Use up to 20 letters, numbers, '-' or '_', starting with a letter or a number.";
100
+ }
101
+ }
102
+ exports.default = LocaleRepository;
@@ -19,7 +19,10 @@ class ResourceRepositoryBase {
19
19
  }
20
20
  }
21
21
  async findAll() {
22
- const { [this._repositoryName]: resources } = await this.workspaceConfig.getActiveTeam();
22
+ // Defaulted because a collection added after a workspace was cloned (as `i18n` was) is absent
23
+ // from its sync metadata until the first pull writes it — findAll must see "no resources",
24
+ // not crash every aggregate pull/push/status in every pre-existing workspace.
25
+ const { [this._repositoryName]: resources = {} } = await this.workspaceConfig.getActiveTeam();
23
26
  // eslint-disable-next-line unicorn/no-array-reduce
24
27
  const existResources = await Object.keys(resources).reduce((promise, slug) => promise.then(async (existIds) => {
25
28
  const exists = await this.exists(slug);
@@ -29,7 +29,7 @@ class TeamRepository {
29
29
  }
30
30
  catch { }
31
31
  const parentTeamSlugs = Array.isArray(raw.parentTeamSlugs) ? raw.parentTeamSlugs : [];
32
- const team = new types_1.Team(slug, parentTeamSlugs, raw.zoneId, this.parseErrorHandler(raw.errorHandlerConfig), this.parseWebhookAuth(raw.webhookAuth));
32
+ const team = new types_1.Team(slug, parentTeamSlugs, raw.zoneId, this.parseErrorHandler(raw.errorHandlerConfig), this.parseWebhookAuth(raw.webhookAuth), typeof raw.primaryLocale === 'string' && raw.primaryLocale.length > 0 ? raw.primaryLocale : undefined);
33
33
  // Parsed so a find()+save() round-trip does not drop the pull-only fields.
34
34
  if (Array.isArray(raw.versions)) {
35
35
  team.versions = raw.versions;
@@ -51,7 +51,7 @@ class TeamRepository {
51
51
  tag: raw.errorHandlerConfig.tag ?? null,
52
52
  };
53
53
  }
54
- return new types_1.Team(raw.slug, raw.parentTeamSlugs ?? [], raw.zoneId, errorHandlerConfig, this.parseWebhookAuth(raw.webhookAuth));
54
+ return new types_1.Team(raw.slug, raw.parentTeamSlugs ?? [], raw.zoneId, errorHandlerConfig, this.parseWebhookAuth(raw.webhookAuth), raw.primaryLocale ?? undefined);
55
55
  }
56
56
  /**
57
57
  * Writes the cloud's team versions and aliases into the Team as the pull-only team.json
@@ -111,6 +111,9 @@ class TeamRepository {
111
111
  if (team.webhookAuth !== undefined) {
112
112
  file.webhookAuth = team.webhookAuth;
113
113
  }
114
+ if (team.primaryLocale !== undefined) {
115
+ file.primaryLocale = team.primaryLocale;
116
+ }
114
117
  // Pull-only global versioning fields, appended after the pushable settings.
115
118
  if (team.versions !== undefined) {
116
119
  file.versions = team.versions;
@@ -64,6 +64,7 @@ fcode = FcodeGlobal(
64
64
  Storage(),
65
65
  Schedule(),
66
66
  Variables(env_variables),
67
+ metadata.get("i18n", {}),
67
68
  )
68
69
 
69
70
  logging.basicConfig(level=os.environ.get("FCODE_LOG_LEVEL", logging.INFO))
@@ -78,8 +78,42 @@ class Processes(dict):
78
78
  print(error, file=sys.stderr)
79
79
 
80
80
 
81
+ class I18n:
82
+ """Local equivalent of the cloud's fcode.i18n.
83
+
84
+ The CLI has already layered the chosen locale over the workspace's primary one, so what
85
+ arrives here is a single flat map. Nothing raises: a key with no translation resolves to the
86
+ key itself, and a placeholder with no argument is left exactly as written.
87
+
88
+ The ``options`` argument (the version a call pins in the cloud) is accepted and ignored: a
89
+ workspace directory holds no published snapshots, so a pinned call resolves against the files
90
+ on disk -- the same per-file fallback the cloud applies to a locale never published at that tag.
91
+ """
92
+
93
+ def __init__(self, i18n_payload):
94
+ payload = i18n_payload or {}
95
+ self.__translations = payload.get("translations") or {}
96
+ self.locale = payload.get("locale")
97
+
98
+ def __call__(self, key, args=None, options=None):
99
+ template = self.__translations.get(key)
100
+ if template is None:
101
+ return key
102
+ if not isinstance(args, dict):
103
+ return template
104
+
105
+ import re
106
+
107
+ def replace(match):
108
+ value = args.get(match.group(1).strip())
109
+ return match.group(0) if value is None else str(value)
110
+
111
+ # Single pass, so a substituted value containing %{...} is never rescanned.
112
+ return re.sub(r"%\{([^}]*)\}", replace, template)
113
+
114
+
81
115
  class FcodeGlobal:
82
- def __init__(self, context, execution, env, datastore, storage, schedule, variables):
116
+ def __init__(self, context, execution, env, datastore, storage, schedule, variables, i18n=None):
83
117
  self.context = context
84
118
  self.execution = execution
85
119
  self.team = AttrsInfo({"timezone": "UTC"})
@@ -89,6 +123,7 @@ class FcodeGlobal:
89
123
  self.schedule = schedule
90
124
  self.variables = variables
91
125
  self.processes = Processes()
126
+ self.i18n = I18n(i18n)
92
127
 
93
128
  def send_mail(self, to=None, subject=None, text=None, html=None):
94
129
  # No manager locally: log the email contents instead of sending, so devs
@@ -110,6 +145,13 @@ class FcodeGlobal:
110
145
  return {"messageId": "local-run", "accepted": accepted, "rejected": []}
111
146
 
112
147
  def import_module(self, module_name, version=None):
148
+ """Import a workspace module, optionally at a version.
149
+
150
+ The version is either a bare string or an options dict ({"version": "v1"}), the form
151
+ every other versioned helper takes — the cloud reads both, so a local run must too.
152
+ """
153
+ if isinstance(version, dict):
154
+ version = version.get("version")
113
155
  module_path = module_name
114
156
  if version:
115
157
  module_path = os.path.join(module_path, "versions", version)