@factorialco/fcode-cli 2.4.0 → 3.1.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 (114) hide show
  1. package/README.md +332 -118
  2. package/dist/commands/clone.d.ts +0 -12
  3. package/dist/commands/clone.js +22 -135
  4. package/dist/commands/{settings → config}/get.d.ts +1 -1
  5. package/dist/commands/{settings → config}/get.js +7 -7
  6. package/dist/commands/{settings → config}/remove.d.ts +1 -1
  7. package/dist/commands/{settings → config}/remove.js +6 -6
  8. package/dist/commands/{settings → config}/set.d.ts +1 -1
  9. package/dist/commands/{settings → config}/set.js +8 -8
  10. package/dist/commands/dependencies/diff.d.ts +9 -0
  11. package/dist/commands/dependencies/diff.js +15 -0
  12. package/dist/commands/dependencies/pull.d.ts +9 -0
  13. package/dist/commands/dependencies/pull.js +44 -1
  14. package/dist/commands/diff.d.ts +12 -0
  15. package/dist/commands/diff.js +48 -0
  16. package/dist/commands/http.d.ts +1 -1
  17. package/dist/commands/http.js +7 -7
  18. package/dist/commands/i18n/diff.d.ts +9 -0
  19. package/dist/commands/i18n/diff.js +15 -0
  20. package/dist/commands/i18n/status.d.ts +3 -0
  21. package/dist/commands/i18n/status.js +1 -0
  22. package/dist/commands/modules/diff.d.ts +9 -0
  23. package/dist/commands/modules/diff.js +15 -0
  24. package/dist/commands/modules/status.d.ts +3 -0
  25. package/dist/commands/modules/status.js +1 -0
  26. package/dist/commands/processes/diff.d.ts +9 -0
  27. package/dist/commands/processes/diff.js +15 -0
  28. package/dist/commands/processes/status.d.ts +3 -0
  29. package/dist/commands/processes/status.js +1 -0
  30. package/dist/commands/pull.js +2 -2
  31. package/dist/commands/push.js +2 -2
  32. package/dist/commands/{team → settings}/aliases/delete.d.ts +1 -1
  33. package/dist/commands/{team → settings}/aliases/delete.js +6 -6
  34. package/dist/commands/{team → settings}/aliases/list.d.ts +1 -1
  35. package/dist/commands/{team → settings}/aliases/list.js +8 -8
  36. package/dist/commands/{team → settings}/aliases/set.d.ts +1 -1
  37. package/dist/commands/{team → settings}/aliases/set.js +8 -8
  38. package/dist/commands/settings/diff.d.ts +8 -0
  39. package/dist/commands/settings/diff.js +39 -0
  40. package/dist/commands/settings/pull.d.ts +11 -0
  41. package/dist/commands/settings/pull.js +41 -0
  42. package/dist/commands/{team → settings}/push.d.ts +4 -4
  43. package/dist/commands/{team → settings}/push.js +39 -39
  44. package/dist/commands/settings/status.d.ts +8 -0
  45. package/dist/commands/settings/status.js +46 -0
  46. package/dist/commands/{team → settings}/versions/create.d.ts +1 -1
  47. package/dist/commands/{team → settings}/versions/create.js +13 -10
  48. package/dist/commands/{team → settings}/versions/delete.d.ts +1 -1
  49. package/dist/commands/{team → settings}/versions/delete.js +10 -10
  50. package/dist/commands/{team → settings}/versions/list.d.ts +1 -1
  51. package/dist/commands/{team → settings}/versions/list.js +8 -8
  52. package/dist/commands/status.d.ts +3 -0
  53. package/dist/commands/status.js +10 -7
  54. package/dist/commands/team/clone.d.ts +14 -0
  55. package/dist/commands/team/clone.js +90 -0
  56. package/dist/commands/team/pull.d.ts +3 -7
  57. package/dist/commands/team/pull.js +24 -30
  58. package/dist/commands/team/status.d.ts +3 -4
  59. package/dist/commands/team/status.js +38 -33
  60. package/dist/commands/variables/diff.d.ts +9 -0
  61. package/dist/commands/variables/diff.js +15 -0
  62. package/dist/commands/variables/status.d.ts +3 -0
  63. package/dist/commands/variables/status.js +1 -0
  64. package/dist/config/dev-team.d.ts +64 -0
  65. package/dist/config/dev-team.js +76 -0
  66. package/dist/config/workspace.d.ts +2 -2
  67. package/dist/config/workspace.js +4 -2
  68. package/dist/core/commands/base-command.js +5 -0
  69. package/dist/core/commands/dependency-command.d.ts +2 -1
  70. package/dist/core/commands/dependency-command.js +3 -0
  71. package/dist/core/commands/i18n-command.js +2 -0
  72. package/dist/core/commands/modules-command.js +2 -0
  73. package/dist/core/commands/processes-command.js +2 -0
  74. package/dist/core/commands/resource-command.d.ts +26 -1
  75. package/dist/core/commands/resource-command.js +100 -3
  76. package/dist/core/commands/variables-command.d.ts +7 -0
  77. package/dist/core/commands/variables-command.js +13 -0
  78. package/dist/core/executors/javascript-execution-task.js +25 -3
  79. package/dist/core/types.d.ts +30 -7
  80. package/dist/core/types.js +38 -10
  81. package/dist/repositories/process.d.ts +8 -2
  82. package/dist/repositories/process.js +28 -18
  83. package/dist/repositories/programming-language-manifest.d.ts +15 -1
  84. package/dist/repositories/programming-language-manifest.js +45 -1
  85. package/dist/repositories/settings.d.ts +52 -0
  86. package/dist/repositories/settings.js +214 -0
  87. package/dist/runtime/libs/python/fcode/context.py +29 -2
  88. package/dist/services/dashboard-client.d.ts +43 -0
  89. package/dist/services/dashboard-client.js +108 -0
  90. package/dist/services/dev-team-service.d.ts +55 -0
  91. package/dist/services/dev-team-service.js +221 -0
  92. package/dist/services/fcode-graphql-client.d.ts +16 -2
  93. package/dist/services/fcode-graphql-client.js +56 -7
  94. package/dist/services/locale-resolver.d.ts +16 -5
  95. package/dist/services/locale-resolver.js +60 -12
  96. package/dist/services/resource-differ.d.ts +60 -0
  97. package/dist/services/resource-differ.js +135 -0
  98. package/dist/services/skills-installer.d.ts +16 -0
  99. package/dist/services/skills-installer.js +77 -0
  100. package/dist/services/team-versioning.d.ts +4 -4
  101. package/dist/services/team-versioning.js +4 -4
  102. package/dist/services/webhook-auth.js +1 -1
  103. package/dist/services/workspace-cloner.d.ts +31 -0
  104. package/dist/services/workspace-cloner.js +95 -0
  105. package/dist/utils/dev-slug.d.ts +12 -0
  106. package/dist/utils/dev-slug.js +37 -0
  107. package/dist/utils/diff.d.ts +19 -0
  108. package/dist/utils/diff.js +58 -0
  109. package/dist/utils/slugify.d.ts +17 -0
  110. package/dist/utils/slugify.js +41 -0
  111. package/oclif.manifest.json +428 -116
  112. package/package.json +12 -7
  113. package/dist/repositories/team.d.ts +0 -41
  114. package/dist/repositories/team.js +0 -170
@@ -0,0 +1,221 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.fetchTeamRemote = fetchTeamRemote;
4
+ exports.planSync = planSync;
5
+ exports.assignFolder = assignFolder;
6
+ exports.syncTeam = syncTeam;
7
+ exports.reportOutcomes = reportOutcomes;
8
+ exports.resolveTeam = resolveTeam;
9
+ const fs = require("fs-extra");
10
+ const path = require("node:path");
11
+ const color_1 = require("@oclif/color");
12
+ const pull_1 = require("../commands/pull");
13
+ const dev_team_1 = require("../config/dev-team");
14
+ const skills_installer_1 = require("./skills-installer");
15
+ const dev_slug_1 = require("../utils/dev-slug");
16
+ const workspace_cloner_1 = require("./workspace-cloner");
17
+ const slugify_1 = require("../utils/slugify");
18
+ const cli_1 = require("../utils/cli");
19
+ async function fetchTeamRemote(client, teamId) {
20
+ const [apps, globalWorkspaces] = await Promise.all([client.listTeamApps(teamId), client.listTeamGlobalWorkspaces(teamId)]);
21
+ return { apps, globalWorkspaces };
22
+ }
23
+ function planSync(recorded, remote) {
24
+ const remoteIds = new Set(remote.map(({ id }) => id));
25
+ return {
26
+ toClone: remote.filter(({ id }) => recorded[id] === undefined),
27
+ toRefresh: remote.filter(({ id }) => recorded[id] !== undefined),
28
+ removed: Object.entries(recorded)
29
+ .filter(([id]) => !remoteIds.has(id))
30
+ .map(([id, entry]) => ({ id, recorded: entry })),
31
+ };
32
+ }
33
+ /**
34
+ * The folder an App gets inside the team root. Names are free text and two Apps may well slugify
35
+ * the same, so the first claimant keeps the plain slug and the next ones are suffixed. Only ever
36
+ * called for an App without a recorded folder — afterwards the team metadata is authoritative, so
37
+ * a renamed App keeps the folder it was cloned into.
38
+ */
39
+ function assignFolder(app, metadata) {
40
+ const taken = new Set(Object.values(metadata.apps).map(({ folder }) => folder));
41
+ return (0, slugify_1.uniqueSlug)((0, slugify_1.slugifyOr)(app.name, app.id), taken);
42
+ }
43
+ /**
44
+ * The team folder holds the skills once (installing them per App would download the same tree N
45
+ * times); the App and its workspace link to them, so an agent started in either place resolves
46
+ * them. The link stays out of git through the App's own .gitignore.
47
+ */
48
+ function linkSkills(rootDir, appDir, workspaceDir) {
49
+ (0, skills_installer_1.linkSkillsInto)(appDir, rootDir);
50
+ (0, skills_installer_1.linkSkillsInto)(workspaceDir, rootDir);
51
+ }
52
+ /**
53
+ * A committed .gitignore keeping the skill links out of the repository: one in each App folder,
54
+ * since each is meant to become its own repository, and one over the global workspaces.
55
+ */
56
+ function writeSkillsGitIgnore(dir) {
57
+ const gitIgnorePath = path.join(dir, '.gitignore');
58
+ if (fs.pathExistsSync(gitIgnorePath)) {
59
+ return;
60
+ }
61
+ fs.outputFileSync(gitIgnorePath, ['# Agent skills are installed once at the development-team root and linked here.', '**/.claude/skills', ''].join('\n'));
62
+ }
63
+ /**
64
+ * Clones one App. The metadata entry is recorded as soon as the directory exists, before the
65
+ * pull that may fail: a half-cloned App has to be recognised as cloned next time, or the retry
66
+ * would try to create it again and stop at "already cloned".
67
+ */
68
+ async function cloneApp(rootDir, app, folder, metadata) {
69
+ const slug = (0, dev_slug_1.devSlug)(app.id);
70
+ const appDir = path.join(rootDir, folder);
71
+ const workspaceDir = path.join(appDir, dev_team_1.APP_WORKSPACE_DIR);
72
+ cli_1.default.log(color_1.default.cyan(`\n📦 ${app.name} → ${folder}/${dev_team_1.APP_WORKSPACE_DIR} (${slug})`));
73
+ await (0, workspace_cloner_1.scaffoldWorkspace)(workspaceDir, { url: metadata.cloudUrl, slug });
74
+ metadata.apps[app.id] = { name: app.name, description: app.description, folder, devWorkspaceSlug: slug };
75
+ (0, dev_team_1.writeAppSettings)(appDir, { id: app.id, name: app.name, description: app.description });
76
+ writeSkillsGitIgnore(appDir);
77
+ linkSkills(rootDir, appDir, workspaceDir);
78
+ await (0, workspace_cloner_1.runInWorkspace)(workspaceDir, () => pull_1.default.run([]));
79
+ }
80
+ async function refreshApp(rootDir, cloudUrl, app, recorded) {
81
+ const appDir = path.join(rootDir, recorded.folder);
82
+ const workspaceDir = path.join(appDir, dev_team_1.APP_WORKSPACE_DIR);
83
+ cli_1.default.log(color_1.default.cyan(`\n📦 ${app.name} → ${recorded.folder}/${dev_team_1.APP_WORKSPACE_DIR}`));
84
+ // A recorded App whose workspace is gone — a failed first clone, or a directory the user
85
+ // removed — is laid out again rather than failing every pull from here on.
86
+ if (!fs.pathExistsSync(path.join(workspaceDir, '.fcode'))) {
87
+ await (0, workspace_cloner_1.scaffoldWorkspace)(workspaceDir, { url: cloudUrl, slug: recorded.devWorkspaceSlug });
88
+ }
89
+ // Name and description live in the dashboard; the local copy is a mirror refreshed on every
90
+ // pull. There is deliberately no team:push — pushing happens inside a workspace.
91
+ (0, dev_team_1.writeAppSettings)(appDir, { id: app.id, name: app.name, description: app.description });
92
+ writeSkillsGitIgnore(appDir);
93
+ recorded.name = app.name;
94
+ recorded.description = app.description;
95
+ linkSkills(rootDir, appDir, workspaceDir);
96
+ await (0, workspace_cloner_1.runInWorkspace)(workspaceDir, () => pull_1.default.run([]));
97
+ }
98
+ /**
99
+ * Clones or refreshes one global workspace. Its slug is the platform team slug — fixed, unique and
100
+ * filesystem-safe — so the checkout goes straight to `global-workspaces/{slug}` with no folder to
101
+ * assign and no App-level settings to mirror. Recorded before the pull for the same reason as an
102
+ * App: a half-cloned checkout must read as cloned on the retry.
103
+ */
104
+ async function syncGlobalWorkspace(rootDir, workspace, metadata) {
105
+ const folder = (0, dev_team_1.globalWorkspaceFolder)(workspace.slug);
106
+ const workspaceDir = path.join(rootDir, folder);
107
+ cli_1.default.log(color_1.default.cyan(`\n🌐 ${workspace.name} → ${folder}`));
108
+ if (!fs.pathExistsSync(path.join(workspaceDir, '.fcode'))) {
109
+ await (0, workspace_cloner_1.scaffoldWorkspace)(workspaceDir, { url: metadata.cloudUrl, slug: workspace.slug });
110
+ }
111
+ metadata.globalWorkspaces[workspace.id] = { name: workspace.name, description: workspace.description, slug: workspace.slug };
112
+ writeSkillsGitIgnore(path.join(rootDir, dev_team_1.GLOBAL_WORKSPACES_DIR));
113
+ (0, skills_installer_1.linkSkillsInto)(workspaceDir, rootDir);
114
+ await (0, workspace_cloner_1.runInWorkspace)(workspaceDir, () => pull_1.default.run([]));
115
+ }
116
+ async function syncApps(rootDir, metadata, plan) {
117
+ const outcomes = [];
118
+ for (const app of plan.toClone) {
119
+ const folder = assignFolder(app, metadata);
120
+ try {
121
+ await cloneApp(rootDir, app, folder, metadata);
122
+ outcomes.push({ name: app.name, folder, action: 'cloned' });
123
+ }
124
+ catch (error) {
125
+ outcomes.push({ name: app.name, folder, action: 'cloned', error: error });
126
+ }
127
+ }
128
+ for (const app of plan.toRefresh) {
129
+ const recorded = metadata.apps[app.id];
130
+ try {
131
+ await refreshApp(rootDir, metadata.cloudUrl, app, recorded);
132
+ outcomes.push({ name: app.name, folder: recorded.folder, action: 'pulled' });
133
+ }
134
+ catch (error) {
135
+ outcomes.push({ name: app.name, folder: recorded.folder, action: 'pulled', error: error });
136
+ }
137
+ }
138
+ return outcomes;
139
+ }
140
+ async function syncGlobalWorkspaces(rootDir, metadata, plan) {
141
+ const outcomes = [];
142
+ for (const [action, workspaces] of [
143
+ ['cloned', plan.toClone],
144
+ ['pulled', plan.toRefresh],
145
+ ]) {
146
+ for (const workspace of workspaces) {
147
+ const folder = (0, dev_team_1.globalWorkspaceFolder)(workspace.slug);
148
+ try {
149
+ await syncGlobalWorkspace(rootDir, workspace, metadata);
150
+ outcomes.push({ name: workspace.name, folder, action });
151
+ }
152
+ catch (error) {
153
+ outcomes.push({ name: workspace.name, folder, action, error: error });
154
+ }
155
+ }
156
+ }
157
+ return outcomes;
158
+ }
159
+ /**
160
+ * Brings the team folder in line with the dashboard — global workspaces first, since the Apps
161
+ * inherit from them, then the Apps — one workspace at a time, recording the metadata of each one
162
+ * that lands. A failing workspace is reported and the run carries on: with a dozen Apps, one bad
163
+ * workspace must not cost the other eleven.
164
+ */
165
+ async function syncTeam(rootDir, metadata, remote) {
166
+ const globalWorkspacePlan = planSync(metadata.globalWorkspaces, remote.globalWorkspaces);
167
+ const appPlan = planSync(metadata.apps, remote.apps);
168
+ const outcomes = [
169
+ ...(await syncGlobalWorkspaces(rootDir, metadata, globalWorkspacePlan)),
170
+ ...(await syncApps(rootDir, metadata, appPlan)),
171
+ ];
172
+ const removedFolders = [
173
+ ...globalWorkspacePlan.removed.map(({ recorded }) => (0, dev_team_1.globalWorkspaceFolder)(recorded.slug)),
174
+ ...appPlan.removed.map(({ recorded }) => recorded.folder),
175
+ ];
176
+ return { outcomes, removedFolders };
177
+ }
178
+ /** Prints the per-workspace result and reports whether anything failed. */
179
+ function reportOutcomes({ outcomes, removedFolders }) {
180
+ cli_1.default.log('');
181
+ for (const outcome of outcomes) {
182
+ if (outcome.error) {
183
+ cli_1.default.log(color_1.default.red(`❌ ${outcome.folder} — ${outcome.error.message}`));
184
+ }
185
+ else {
186
+ cli_1.default.log(color_1.default.green(`✅ ${outcome.folder} (${outcome.action})`));
187
+ }
188
+ }
189
+ for (const folder of removedFolders) {
190
+ cli_1.default.log(color_1.default.yellow(`⚠️ ${folder} is no longer in the team — the folder was kept, remove it yourself`));
191
+ }
192
+ return outcomes.some(({ error }) => error !== undefined);
193
+ }
194
+ /**
195
+ * The team to operate on: the one named, the only one the session belongs to, or — when there are
196
+ * several — none, after listing them so the user can pass an id.
197
+ */
198
+ async function resolveTeam(client, teamId) {
199
+ const teams = await client.listDevelopmentTeams();
200
+ if (teamId) {
201
+ const team = teams.find(({ id }) => id === teamId);
202
+ if (!team) {
203
+ process.exitCode = 1;
204
+ cli_1.default.error(`Development team ${teamId} not found, or your session has no access to it`);
205
+ }
206
+ return team;
207
+ }
208
+ if (teams.length === 0) {
209
+ cli_1.default.log('ℹ️ Your session belongs to no development team. Ask your team admin to add you.');
210
+ return undefined;
211
+ }
212
+ if (teams.length === 1) {
213
+ return teams[0];
214
+ }
215
+ cli_1.default.log('🏢 Your development teams:\n');
216
+ for (const team of teams) {
217
+ cli_1.default.log(` ${color_1.default.cyan(team.id)} ${team.name}`);
218
+ }
219
+ cli_1.default.log(`\nClone one with ${color_1.default.cyan('fcode team:clone [teamId]')}`);
220
+ return undefined;
221
+ }
@@ -1,5 +1,5 @@
1
1
  import { GraphQLClient } from 'graphql-request';
2
- import { Locale, 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, VersionedProgrammingLanguageManifest, VersionedScriptLibrary, ServiceAccount } from '../core/types';
3
3
  export type FcodeGraphQLClientOptions = {
4
4
  url: string;
5
5
  accessToken: string;
@@ -50,7 +50,7 @@ declare class FcodeGraphQLClient {
50
50
  private mapWebhook;
51
51
  mapProcess({ id, slug, name, description, tags, script, readme, manifest, settings, triggers, resolvingTeamSlug }: any): Process;
52
52
  /**
53
- * GraphQL nests the webhook configuration under `settings`, while team.json and the REST API
53
+ * GraphQL nests the webhook configuration under `settings`, while settings.json and the REST API
54
54
  * keep it flat. Mapping it explicitly is what keeps that mismatch from silently reading as
55
55
  * undefined.
56
56
  */
@@ -69,6 +69,20 @@ declare class FcodeGraphQLClient {
69
69
  pushTeam(update: TeamUpdateInput): Promise<RawTeamSettings>;
70
70
  mapProgrammingLanguageManifest(programmingLanguageManifest: any): ProgrammingLanguageManifest;
71
71
  private mapDependencies;
72
+ /**
73
+ * The published dependency versions of one language, newest first. An fcode-api that predates
74
+ * dependency versioning rejects the query over the unknown field; that answers `null` — "the api
75
+ * cannot say" — so the pull skips versions entirely instead of reconciling (and deleting) local
76
+ * snapshot folders against an empty list. Only that schema-validation rejection is swallowed: a
77
+ * runtime failure of the field also mentions its name, and must fail the pull.
78
+ */
79
+ allVersionedDependencies(programmingLanguage: ProgrammingLanguage): Promise<Array<VersionedProgrammingLanguageManifest> | null>;
80
+ /**
81
+ * Whether the api rejected the query because it does not know {@code field} — as opposed to
82
+ * knowing it and failing to resolve it, whose message also contains the field name (graphql-java
83
+ * puts the path into runtime messages: "Exception while fetching data (/field) : ...").
84
+ */
85
+ private isUndefinedFieldError;
72
86
  allProgrammingLanguageManifest(): Promise<Array<ProgrammingLanguageManifest>>;
73
87
  pushProgrammingLanguageManifest(programmingLanguageManifest: ProgrammingLanguageManifest): Promise<ProgrammingLanguageManifest>;
74
88
  allServiceAccounts(): Promise<Array<ServiceAccount>>;
@@ -362,9 +362,9 @@ class FcodeGraphQLClient {
362
362
  return Promise.reject(this.cleanError(error));
363
363
  }
364
364
  // The cloud team's parent order is the inheritance precedence the merge must replicate; the
365
- // local team.json may lag behind it, so the cloud is asked, not the file.
366
- const { parentTeamSlugs } = await this.team();
367
- const merged = (0, locale_merger_1.mergeInheritedLocales)(locales, parentTeamSlugs ?? []);
365
+ // local settings.json may lag behind it, so the cloud is asked, not the file.
366
+ const { parentTeams } = await this.team();
367
+ const merged = (0, locale_merger_1.mergeInheritedLocales)(locales, (parentTeams ?? []).map(({ slug }) => slug));
368
368
  (0, locale_merger_1.assertLocaleFilesCanCoexist)(merged);
369
369
  return merged;
370
370
  }
@@ -786,7 +786,7 @@ class FcodeGraphQLClient {
786
786
  return new types_1.Process(id, slug, script, readme, { dependencies }, settings, metadata, this.inheritedTeam(resolvingTeamSlug));
787
787
  }
788
788
  /**
789
- * GraphQL nests the webhook configuration under `settings`, while team.json and the REST API
789
+ * GraphQL nests the webhook configuration under `settings`, while settings.json and the REST API
790
790
  * keep it flat. Mapping it explicitly is what keeps that mismatch from silently reading as
791
791
  * undefined.
792
792
  */
@@ -795,7 +795,7 @@ class FcodeGraphQLClient {
795
795
  slug: team.slug,
796
796
  zoneId: team.zoneId,
797
797
  errorHandlerConfig: team.errorHandlerConfig,
798
- parentTeamSlugs: team.parentTeamSlugs ?? [],
798
+ parentTeams: team.parentTeams ?? [],
799
799
  };
800
800
  const webhookAuth = team.settings?.webhookAuth;
801
801
  if (webhookAuth?.variableKey) {
@@ -947,7 +947,10 @@ class FcodeGraphQLClient {
947
947
  processId
948
948
  tag
949
949
  }
950
- parentTeamSlugs
950
+ parentTeams {
951
+ slug
952
+ version
953
+ }
951
954
  settings {
952
955
  webhookAuth {
953
956
  headerName
@@ -974,7 +977,10 @@ class FcodeGraphQLClient {
974
977
  processId
975
978
  tag
976
979
  }
977
- parentTeamSlugs
980
+ parentTeams {
981
+ slug
982
+ version
983
+ }
978
984
  settings {
979
985
  webhookAuth {
980
986
  headerName
@@ -1010,6 +1016,49 @@ class FcodeGraphQLClient {
1010
1016
  .filter(Boolean)
1011
1017
  .sort((a, b) => a.name.localeCompare(b.name));
1012
1018
  }
1019
+ /**
1020
+ * The published dependency versions of one language, newest first. An fcode-api that predates
1021
+ * dependency versioning rejects the query over the unknown field; that answers `null` — "the api
1022
+ * cannot say" — so the pull skips versions entirely instead of reconciling (and deleting) local
1023
+ * snapshot folders against an empty list. Only that schema-validation rejection is swallowed: a
1024
+ * runtime failure of the field also mentions its name, and must fail the pull.
1025
+ */
1026
+ allVersionedDependencies(programmingLanguage) {
1027
+ return this.client
1028
+ .request((0, graphql_request_1.gql) `
1029
+ query allVersionedDependencies($programmingLanguage: ProgrammingLanguage!) {
1030
+ allVersionedDependencies(programmingLanguage: $programmingLanguage) {
1031
+ tag
1032
+ programmingLanguage
1033
+ dependencies {
1034
+ name
1035
+ semVer
1036
+ isNative
1037
+ gitUrl
1038
+ }
1039
+ }
1040
+ }
1041
+ `, { programmingLanguage })
1042
+ .then(({ allVersionedDependencies }) => allVersionedDependencies.map((versioned) => new types_1.VersionedProgrammingLanguageManifest(versioned.tag, versioned.programmingLanguage, this.mapDependencies(versioned.dependencies))))
1043
+ .catch((error) => {
1044
+ if (this.isUndefinedFieldError(error, 'allVersionedDependencies')) {
1045
+ return null;
1046
+ }
1047
+ return Promise.reject(this.cleanError(error));
1048
+ });
1049
+ }
1050
+ /**
1051
+ * Whether the api rejected the query because it does not know {@code field} — as opposed to
1052
+ * knowing it and failing to resolve it, whose message also contains the field name (graphql-java
1053
+ * puts the path into runtime messages: "Exception while fetching data (/field) : ...").
1054
+ */
1055
+ isUndefinedFieldError(error, field) {
1056
+ const first = error.response?.errors?.[0];
1057
+ if (!first?.message?.includes(field)) {
1058
+ return false;
1059
+ }
1060
+ return first.extensions?.classification === 'ValidationError' || first.message.startsWith('Validation error');
1061
+ }
1013
1062
  allProgrammingLanguageManifest() {
1014
1063
  const result = [];
1015
1064
  result.push(this.programmingLanguageManifest('JAVASCRIPT'));
@@ -8,13 +8,24 @@
8
8
  * ones, which `pull` writes into the same directory as `<locale>.inherited.yaml`.
9
9
  */
10
10
  export declare function flattenTranslations(document: unknown, prefix?: string, target?: Record<string, string>): Record<string, string>;
11
+ /**
12
+ * The locale files that answer a requested identifier, lowest precedence first. Mirrors the
13
+ * platform's `LocaleFallbackChain`, so a local run and the cloud resolve the same text.
14
+ *
15
+ * A viewer's locale is usually a regional variant (`en-gb`, `pt-BR`) while a workspace usually
16
+ * publishes plain languages, so the request degrades one segment at a time — split on `-` or `_`,
17
+ * without parsing a language tag, since any short slug is a legal identifier here — and every step
18
+ * that matches stays in the chain: a variant file translates the keys that differ and leaves the
19
+ * rest to the language it came from.
20
+ */
21
+ export declare function localeFallbackChain(requested: string | undefined, primary: string | undefined, available: Array<string>): Array<string>;
11
22
  /**
12
23
  * @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
- * plus each locale's own map (inherited file layered under its own, but NOT merged over the
15
- * primary) so a call naming a locale (`fcode.i18n(key, args, { locale })`) resolves like the
16
- * cloud's `by_locale`: key by key in the requested locale first, falling back to the merged
17
- * translations
24
+ * @returns the locale actually used and its flat translations, with the primary locale — and any
25
+ * shorter form of the requested one the workspace publishes — layered underneath, plus each
26
+ * locale's own map (inherited file layered under its own, but NOT merged over the primary) so a
27
+ * call naming a locale (`fcode.i18n(key, args, { locale })`) resolves like the cloud's `by_locale`:
28
+ * key by key in the requested locale first, falling back to the merged translations
18
29
  */
19
30
  export declare function resolveLocalTranslations(requestedLocale?: string, workspaceDir?: string): {
20
31
  locale?: string;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.flattenTranslations = flattenTranslations;
4
+ exports.localeFallbackChain = localeFallbackChain;
4
5
  exports.resolveLocalTranslations = resolveLocalTranslations;
5
6
  const fs = require("fs-extra");
6
7
  const path = require("node:path");
@@ -8,6 +9,7 @@ const yaml_1 = require("yaml");
8
9
  const cli_1 = require("../utils/cli");
9
10
  const color_1 = require("@oclif/color");
10
11
  const types_1 = require("../core/types");
12
+ const settings_1 = require("../repositories/settings");
11
13
  const I18N_DIRECTORY = 'i18n';
12
14
  const YAML_EXTENSION = '.yaml';
13
15
  const KEY_SEPARATOR = '.';
@@ -71,8 +73,9 @@ function readLocale(workspaceDir, locale) {
71
73
  }
72
74
  function chosenPrimaryLocaleOf(workspaceDir) {
73
75
  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
+ (0, settings_1.migrateLegacySettingsFile)(workspaceDir);
77
+ const settings = fs.readJsonSync(path.join(workspaceDir, settings_1.SETTINGS_FILENAME));
78
+ return typeof settings?.primaryLocale === 'string' && settings.primaryLocale.length > 0 ? settings.primaryLocale : undefined;
76
79
  }
77
80
  catch {
78
81
  return undefined;
@@ -95,7 +98,7 @@ function availableLocales(workspaceDir) {
95
98
  return [...new Set(localeFileSlugs(workspaceDir).map((slug) => identifierOf(slug)))].sort();
96
99
  }
97
100
  /**
98
- * The workspace's primary locale: the one chosen in `team.json`, or its first locale when none is
101
+ * The workspace's primary locale: the one chosen in `settings.json`, or its first locale when none is
99
102
  * chosen — or when the chosen one has no file anymore, which would otherwise resolve zero keys.
100
103
  * Mirrors the platform's `PrimaryLocale.resolve`, so a local run and the cloud agree on which
101
104
  * locale a process without an explicit one resolves against.
@@ -105,13 +108,55 @@ function primaryLocaleOf(workspaceDir) {
105
108
  const chosen = chosenPrimaryLocaleOf(workspaceDir);
106
109
  return chosen && available.includes(chosen) ? chosen : available[0];
107
110
  }
111
+ /**
112
+ * The available locale a candidate opens: the one named exactly, or — only when no name matches —
113
+ * the one that differs from it in case alone, alphabetically first if there are several. Locale
114
+ * identifiers are case-sensitive, so `pt-BR` and `pt-br` are two files and each keeps answering its
115
+ * own name; ignoring case only ever rescues a lookup that would otherwise find nothing.
116
+ */
117
+ function matchingLocale(candidate, available) {
118
+ if (available.includes(candidate)) {
119
+ return candidate;
120
+ }
121
+ return available.filter((locale) => locale.toLowerCase() === candidate.toLowerCase()).sort()[0];
122
+ }
123
+ /**
124
+ * The locale files that answer a requested identifier, lowest precedence first. Mirrors the
125
+ * platform's `LocaleFallbackChain`, so a local run and the cloud resolve the same text.
126
+ *
127
+ * A viewer's locale is usually a regional variant (`en-gb`, `pt-BR`) while a workspace usually
128
+ * publishes plain languages, so the request degrades one segment at a time — split on `-` or `_`,
129
+ * without parsing a language tag, since any short slug is a legal identifier here — and every step
130
+ * that matches stays in the chain: a variant file translates the keys that differ and leaves the
131
+ * rest to the language it came from.
132
+ */
133
+ function localeFallbackChain(requested, primary, available) {
134
+ const chain = [];
135
+ const add = (candidate) => {
136
+ const match = candidate ? matchingLocale(candidate, available) : undefined;
137
+ if (match && !chain.includes(match)) {
138
+ chain.push(match);
139
+ }
140
+ };
141
+ add(primary);
142
+ const candidates = [];
143
+ let candidate = requested?.trim();
144
+ while (candidate) {
145
+ candidates.unshift(candidate);
146
+ const lastSeparator = Math.max(candidate.lastIndexOf('-'), candidate.lastIndexOf('_'));
147
+ candidate = lastSeparator > 0 ? candidate.slice(0, lastSeparator) : undefined;
148
+ }
149
+ for (const shorterOrExact of candidates)
150
+ add(shorterOrExact);
151
+ return chain;
152
+ }
108
153
  /**
109
154
  * @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
- * plus each locale's own map (inherited file layered under its own, but NOT merged over the
112
- * primary) so a call naming a locale (`fcode.i18n(key, args, { locale })`) resolves like the
113
- * cloud's `by_locale`: key by key in the requested locale first, falling back to the merged
114
- * translations
155
+ * @returns the locale actually used and its flat translations, with the primary locale — and any
156
+ * shorter form of the requested one the workspace publishes — layered underneath, plus each
157
+ * locale's own map (inherited file layered under its own, but NOT merged over the primary) so a
158
+ * call naming a locale (`fcode.i18n(key, args, { locale })`) resolves like the cloud's `by_locale`:
159
+ * key by key in the requested locale first, falling back to the merged translations
115
160
  */
116
161
  function resolveLocalTranslations(requestedLocale, workspaceDir = process.cwd()) {
117
162
  const primary = primaryLocaleOf(workspaceDir);
@@ -120,8 +165,11 @@ function resolveLocalTranslations(requestedLocale, workspaceDir = process.cwd())
120
165
  if (!locale) {
121
166
  return { locale: undefined, translations: {}, byLocale };
122
167
  }
123
- // `byLocale` already holds every locale's own file, so the merged map — the requested locale
124
- // layered over the primary — is derived from it rather than read from disk a second time.
125
- const fallback = primary ? byLocale[primary] : {};
126
- return { locale, translations: { ...fallback, ...(byLocale[locale] ?? {}) }, byLocale };
168
+ // `byLocale` already holds every locale's own file, so the merged map — the fallback chain
169
+ // layered lowest precedence first — is derived from it rather than read from disk a second time.
170
+ const translations = {};
171
+ for (const layer of localeFallbackChain(requestedLocale, primary, Object.keys(byLocale))) {
172
+ Object.assign(translations, byLocale[layer]);
173
+ }
174
+ return { locale, translations, byLocale };
127
175
  }
@@ -0,0 +1,60 @@
1
+ import { Locale, Process, ProgrammingLanguageManifest, Resource, ResourceStatus, ScriptLibrary, Variable } from '../core/types';
2
+ import { PseudoFile } from '../utils/diff';
3
+ /** Stands in for a variable's value wherever one side declares it sensitive, so no secret is ever printed. */
4
+ export declare const SENSITIVE_PLACEHOLDER = "<sensitive value hidden>";
5
+ export declare const VARIABLES_DIFF_PATH = "variables.env";
6
+ /** What a workspace inherits is regenerated on pull into its own file, which the parent owns. */
7
+ export declare const INHERITED_VARIABLES_DIFF_PATH = "variables.inherited.env";
8
+ export declare const SETTINGS_DIFF_PATH = "settings.json";
9
+ /**
10
+ * A resource as both sides hold it, kept whole. `ResourceMerger.mergeStatus` collapses the two into
11
+ * one object and loses the remote body, which is exactly what a diff needs.
12
+ */
13
+ export type ResourcePair<R extends Resource> = {
14
+ slug: string;
15
+ remote?: R;
16
+ local?: R;
17
+ status: ResourceStatus;
18
+ };
19
+ /** A set of pseudo-files presented together under one heading (usually one resource). */
20
+ export type DiffSection = {
21
+ label: string;
22
+ files: PseudoFile[];
23
+ };
24
+ /** Files keyed by the path they are stored at, for one side of a comparison. */
25
+ type SideFiles = Record<string, string>;
26
+ export declare function pairResources<R extends Resource>(remoteResources: Array<R>, localResources: Array<R>, existsMetadata: (slug: string) => Promise<boolean>): Promise<Array<ResourcePair<R>>>;
27
+ /**
28
+ * The workspace a resource is inherited from, when it is. An overridden variable resolves to
29
+ * undefined on its local side, which is what makes an override diffable while the parent's copy
30
+ * stays read-only.
31
+ */
32
+ export declare function inheritedFrom<R extends Resource>(pair: ResourcePair<R>): string | undefined;
33
+ /** A process as the four files it is stored as, plus its own dependencies file when it scopes them. */
34
+ export declare function processFiles(process: Process): SideFiles;
35
+ export declare function moduleFiles(module: ScriptLibrary): SideFiles;
36
+ export declare function localeFiles(locale: Locale): SideFiles;
37
+ /**
38
+ * Only the workspace's own dependencies, matching what `contentHash` covers and what the manifest
39
+ * file holds: the inherited ones live in their own file and belong to a parent.
40
+ */
41
+ export declare function manifestFiles(manifest: ProgrammingLanguageManifest): SideFiles;
42
+ export declare const processDiffFiles: (pair: ResourcePair<Process>) => Array<PseudoFile>;
43
+ export declare const moduleDiffFiles: (pair: ResourcePair<ScriptLibrary>) => Array<PseudoFile>;
44
+ export declare const localeDiffFiles: (pair: ResourcePair<Locale>) => Array<PseudoFile>;
45
+ export declare const manifestDiffFiles: (pair: ResourcePair<ProgrammingLanguageManifest>) => Array<PseudoFile>;
46
+ /**
47
+ * Every variable as the .env files they share, rather than one pseudo-file each: that is how they
48
+ * are stored, and it keeps a rename or a reordering readable as one patch.
49
+ *
50
+ * The workspace's own keys and the inherited ones are separate files, because they are separate
51
+ * files on disk — a drifted inherited variable is a stale `variables.inherited.env` that a pull
52
+ * regenerates, not something to push. The inherited file only appears when something is inherited.
53
+ *
54
+ * A key either side marks sensitive is rendered as a placeholder on both, so a diff never prints a
55
+ * secret and a value-only change to a sensitive variable shows up as nothing (its status does).
56
+ */
57
+ export declare function variablesEnvDiffFiles(pairs: Array<ResourcePair<Variable>>): Array<PseudoFile>;
58
+ /** The singleton workspace settings file. Both sides arrive already serialized by `SettingsRepository.toFile`. */
59
+ export declare function settingsDiffFile(remote: Record<string, any> | undefined, local: Record<string, any> | undefined): PseudoFile;
60
+ export {};