@factorialco/fcode-cli 2.2.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 CHANGED
@@ -20,7 +20,7 @@ $ npm install -g @factorialco/fcode-cli
20
20
  $ fcode COMMAND
21
21
  running command...
22
22
  $ fcode (--version)
23
- @factorialco/fcode-cli/2.2.0 linux-x64 node-v22.22.3
23
+ @factorialco/fcode-cli/2.3.0 linux-x64 node-v22.22.3
24
24
  $ fcode --help [COMMAND]
25
25
  USAGE
26
26
  $ fcode COMMAND
@@ -7,6 +7,7 @@ const path = require("node:path");
7
7
  const global_1 = require("../config/global");
8
8
  const workspace_1 = require("../config/workspace");
9
9
  const pull_1 = require("./pull");
10
+ const team_access_1 = require("../services/team-access");
10
11
  const types_1 = require("../core/types");
11
12
  const cli_1 = require("../utils/cli");
12
13
  const settings_1 = require("../config/settings");
@@ -120,6 +121,7 @@ class Clone extends core_1.Command {
120
121
  });
121
122
  }
122
123
  async catch(error) {
124
+ process.exitCode = 1;
123
125
  const suggestions = error?.suggestions ?? [];
124
126
  const hint = suggestions.length > 0 ? ` Try this: ${suggestions.map((suggestion) => `'${suggestion}'`).join('\n')} \n` : '';
125
127
  cli_1.default.error(error.message + hint);
@@ -180,8 +182,19 @@ class Clone extends core_1.Command {
180
182
  if (skipSkillsSetup === undefined) {
181
183
  skipSkillsSetup = (await new global_1.default().getSetting(settings_1.SettingKeys.SKIP_SKILLS_SETUP)) === 'true';
182
184
  }
185
+ // Pre-flight access check so a session without access to the team fails here,
186
+ // before any workspace directory, config, or skills are created.
187
+ await team_access_1.default.ensureTeamAccess(url, slug);
183
188
  await this.cloneTeam({ url, slug }, skipSkillsSetup);
184
- cli_1.default.log(`\nšŸŽ‰ ${args.team} team workspace cloned successfully!\n`);
189
+ // Failed pulls don't abort the clone: each sub-pull reports through
190
+ // BaseCommand.catch, which prints and sets a non-zero exit code.
191
+ if (process.exitCode) {
192
+ cli_1.default.log(`\nāš ļø '${args.team}' team workspace cloned with errors — some resources failed to pull.`);
193
+ cli_1.default.log(` Fix the errors above, then run 'fcode pull' inside the workspace.\n`);
194
+ }
195
+ else {
196
+ cli_1.default.log(`\nšŸŽ‰ ${args.team} team workspace cloned successfully!\n`);
197
+ }
185
198
  cli_1.default.log(`$ cd ${slug}\n`);
186
199
  }
187
200
  }
@@ -3,4 +3,5 @@ import FcodeGraphQLClient from '../../services/fcode-graphql-client';
3
3
  export default abstract class ApiClientCommand extends BaseCommand {
4
4
  _fcodeGraphQLClient: FcodeGraphQLClient;
5
5
  init(): Promise<void>;
6
+ protected onSessionRefreshed(): Promise<void>;
6
7
  }
@@ -26,5 +26,10 @@ class ApiClientCommand extends base_command_1.default {
26
26
  throw error;
27
27
  }
28
28
  }
29
+ async onSessionRefreshed() {
30
+ const { url } = await this.workspaceConfig.getTeamOrigin();
31
+ const { token: { access_token: accessToken }, } = await this.globalConfig.getAuthInfo(url);
32
+ this._fcodeGraphQLClient.setAccessToken(accessToken);
33
+ }
29
34
  }
30
35
  exports.default = ApiClientCommand;
@@ -5,7 +5,9 @@ export default abstract class BaseCommand extends Command {
5
5
  url?: string;
6
6
  globalConfig: GlobalConfig;
7
7
  workspaceConfig: WorkspaceConfig;
8
+ private sessionRefreshRetried;
8
9
  catch(error: any): Promise<any>;
9
10
  init(url?: string): Promise<void>;
11
+ protected onSessionRefreshed(): Promise<void>;
10
12
  protected logActiveTeam(): Promise<void>;
11
13
  }
@@ -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})` : ''}'.`));
@@ -47,6 +47,10 @@ export declare class NotAuthenticated extends CLIError {
47
47
  constructor(url: string);
48
48
  getUrl(): string;
49
49
  }
50
+ export declare function teamAccessDeniedMessage(slug?: string): string;
51
+ export declare class TeamAccessDenied extends CLIError {
52
+ constructor(slug?: string);
53
+ }
50
54
  export declare class NotFcodeWorkspace extends CLIError {
51
55
  constructor();
52
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.CloudLocaleNotFound = exports.LocalLocaleNotFound = 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) {
@@ -120,6 +121,20 @@ class NotAuthenticated extends CLIError {
120
121
  }
121
122
  }
122
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;
123
138
  class NotFcodeWorkspace extends CLIError {
124
139
  constructor() {
125
140
  super('āš ļø Current directory is not a Factorial Code workspace.', {
@@ -214,7 +214,9 @@ class Team extends Resource {
214
214
  const payload = {
215
215
  zoneId: this.zoneId || null,
216
216
  errorHandlerConfig: this.errorHandlerConfig || null,
217
- 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 || [])],
218
220
  };
219
221
  // Inserted only when set, so every team.json that predates webhook auth keeps the
220
222
  // hash already stored in .fcode and does not report a phantom conflict. A key present
@@ -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;
@@ -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;
@@ -63,6 +64,7 @@ declare class FcodeGraphQLClient {
63
64
  /** The API upserts: an existing alias with the same name is re-pointed to the tag. */
64
65
  createTeamVersionAlias(name: string, tag: string): Promise<TeamVersionAlias>;
65
66
  deleteTeamVersionAlias(id: string): Promise<TeamVersionAlias>;
67
+ verifyTeamAccess(): Promise<void>;
66
68
  team(): Promise<RawTeamSettings>;
67
69
  pushTeam(update: TeamUpdateInput): Promise<RawTeamSettings>;
68
70
  mapProgrammingLanguageManifest(programmingLanguageManifest: any): ProgrammingLanguageManifest;
@@ -90,6 +90,11 @@ class FcodeGraphQLClient {
90
90
  },
91
91
  });
92
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
+ }
93
98
  isDuplicatedError(error) {
94
99
  return (error &&
95
100
  error.response &&
@@ -345,7 +350,10 @@ class FcodeGraphQLClient {
345
350
  catch (error) {
346
351
  return Promise.reject(this.cleanError(error));
347
352
  }
348
- const merged = (0, locale_merger_1.mergeInheritedLocales)(locales);
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 ?? []);
349
357
  (0, locale_merger_1.assertLocaleFilesCanCoexist)(merged);
350
358
  return merged;
351
359
  }
@@ -860,6 +868,23 @@ class FcodeGraphQLClient {
860
868
  return Promise.reject(this.cleanError(error));
861
869
  });
862
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
+ }
863
888
  team() {
864
889
  return this.client
865
890
  .request((0, graphql_request_1.gql) `
@@ -11,7 +11,7 @@ import { Locale } from '../core/types';
11
11
  * several parents define is rewritten, as a flat mapping of dotted keys, which is the shape the
12
12
  * lookup uses anyway and the one shape where a nested and a dotted key cannot disagree.
13
13
  */
14
- export declare function mergeInheritedLocales(locales: Array<Locale>): Array<Locale>;
14
+ export declare function mergeInheritedLocales(locales: Array<Locale>, parentTeamSlugs: Array<string>): Array<Locale>;
15
15
  /**
16
16
  * Locale identifiers are case-sensitive on the platform (pt-BR and pt-br are different locales),
17
17
  * but the filesystems macOS and Windows usually run on are not — both identifiers would collapse
@@ -22,34 +22,37 @@ const locale_resolver_1 = require("./locale-resolver");
22
22
  * several parents define is rewritten, as a flat mapping of dotted keys, which is the shape the
23
23
  * lookup uses anyway and the one shape where a nested and a dotted key cannot disagree.
24
24
  */
25
- function mergeInheritedLocales(locales) {
25
+ function mergeInheritedLocales(locales, parentTeamSlugs) {
26
26
  const inheritedByIdentifier = new Map();
27
27
  for (const locale of locales.filter((locale) => locale.resolvingTeamSlug)) {
28
28
  inheritedByIdentifier.set(locale.locale, [...(inheritedByIdentifier.get(locale.locale) ?? []), locale]);
29
29
  }
30
30
  return [
31
31
  ...locales.filter((locale) => !locale.resolvingTeamSlug),
32
- ...[...inheritedByIdentifier.values()].map((files) => (files.length === 1 ? files[0] : mergeLocaleFiles(files))),
32
+ ...[...inheritedByIdentifier.values()].map((files) => files.length === 1 ? files[0] : mergeLocaleFiles(files, parentTeamSlugs)),
33
33
  ];
34
34
  }
35
35
  /**
36
- * The platform walks parents sorted by their slug — `Team.parentTeamIdsInResolutionOrder` — and
37
- * the first defining a key wins. Deriving that order here, rather than trusting the order the API
38
- * happened to list the rows in (which follows the listing's `orderBy`, `updatedAt` by default),
39
- * is what keeps a local run and the cloud resolving the same text.
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.
40
41
  */
41
- function inResolutionOrder(files) {
42
- return [...files].sort((a, b) => {
43
- const [aSlug, bSlug] = [a.resolvingTeamSlug ?? '', b.resolvingTeamSlug ?? ''];
44
- return aSlug < bSlug ? -1 : (aSlug > bSlug ? 1 : 0);
45
- });
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));
46
48
  }
47
49
  /**
48
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
49
52
  * @returns one locale carrying their merged translations, attributed to the winning parent
50
53
  */
51
- function mergeLocaleFiles(files) {
52
- const ordered = inResolutionOrder(files);
54
+ function mergeLocaleFiles(files, parentTeamSlugs) {
55
+ const ordered = inResolutionOrder(files, parentTeamSlugs);
53
56
  const [winner] = ordered;
54
57
  const translations = {};
55
58
  // Lowest precedence first, so the first parent in resolution order is the text that survives —
@@ -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;
@@ -2163,5 +2163,5 @@
2163
2163
  ]
2164
2164
  }
2165
2165
  },
2166
- "version": "2.2.0"
2166
+ "version": "2.3.0"
2167
2167
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@factorialco/fcode-cli",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "packageManager": "pnpm@11.7.0",
5
5
  "description": "Factorial Code Command Line Interface",
6
6
  "homepage": "https://code.factorialhr.com/docs/cli/",