revisium 2.3.0 → 2.5.0-alpha.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 (235) hide show
  1. package/.github/workflows/build.yml +11 -30
  2. package/.github/workflows/ci.yml +0 -6
  3. package/.github/workflows/npm-publish.yml +13 -78
  4. package/.github/workflows/release-train.yml +90 -0
  5. package/AGENTS.md +98 -0
  6. package/README.md +31 -13
  7. package/dist/e2e/utils/cli-runner.d.ts +2 -0
  8. package/dist/e2e/utils/cli-runner.js +14 -7
  9. package/dist/e2e/utils/cli-runner.js.map +1 -1
  10. package/dist/e2e/utils/matrix-fixtures.d.ts +26 -0
  11. package/dist/e2e/utils/matrix-fixtures.js +109 -0
  12. package/dist/e2e/utils/matrix-fixtures.js.map +1 -0
  13. package/dist/e2e/utils/matrix-workspace.d.ts +25 -0
  14. package/dist/e2e/utils/matrix-workspace.js +61 -0
  15. package/dist/e2e/utils/matrix-workspace.js.map +1 -0
  16. package/dist/e2e/utils/standalone-api.d.ts +53 -0
  17. package/dist/e2e/utils/standalone-api.js +146 -0
  18. package/dist/e2e/utils/standalone-api.js.map +1 -0
  19. package/dist/e2e/utils/standalone-runner.d.ts +23 -0
  20. package/dist/e2e/utils/standalone-runner.js +182 -0
  21. package/dist/e2e/utils/standalone-runner.js.map +1 -0
  22. package/dist/package.json +6 -3
  23. package/dist/src/app.module.js +51 -0
  24. package/dist/src/app.module.js.map +1 -1
  25. package/dist/src/commands/auth/auth-command.utils.d.ts +8 -0
  26. package/dist/src/commands/auth/auth-command.utils.js +28 -0
  27. package/dist/src/commands/auth/auth-command.utils.js.map +1 -0
  28. package/dist/src/commands/auth/auth-login.command.d.ts +29 -0
  29. package/dist/src/commands/auth/auth-login.command.js +155 -0
  30. package/dist/src/commands/auth/auth-login.command.js.map +1 -0
  31. package/dist/src/commands/auth/auth-logout.command.d.ts +19 -0
  32. package/dist/src/commands/auth/auth-logout.command.js +86 -0
  33. package/dist/src/commands/auth/auth-logout.command.js.map +1 -0
  34. package/dist/src/commands/auth/auth-status.command.d.ts +19 -0
  35. package/dist/src/commands/auth/auth-status.command.js +101 -0
  36. package/dist/src/commands/auth/auth-status.command.js.map +1 -0
  37. package/dist/src/commands/auth/auth.command.d.ts +5 -0
  38. package/dist/src/commands/auth/auth.command.js +35 -0
  39. package/dist/src/commands/auth/auth.command.js.map +1 -0
  40. package/dist/src/commands/base-sync.command.d.ts +2 -2
  41. package/dist/src/commands/base-sync.command.js +1 -2
  42. package/dist/src/commands/base-sync.command.js.map +1 -1
  43. package/dist/src/commands/base.command.d.ts +2 -0
  44. package/dist/src/commands/base.command.js +13 -0
  45. package/dist/src/commands/base.command.js.map +1 -1
  46. package/dist/src/commands/context/context-create.command.d.ts +28 -0
  47. package/dist/src/commands/context/context-create.command.js +172 -0
  48. package/dist/src/commands/context/context-create.command.js.map +1 -0
  49. package/dist/src/commands/context/context-list.command.d.ts +9 -0
  50. package/dist/src/commands/context/context-list.command.js +46 -0
  51. package/dist/src/commands/context/context-list.command.js.map +1 -0
  52. package/dist/src/commands/context/context-remove.command.d.ts +9 -0
  53. package/dist/src/commands/context/context-remove.command.js +48 -0
  54. package/dist/src/commands/context/context-remove.command.js.map +1 -0
  55. package/dist/src/commands/context/context-show.command.d.ts +9 -0
  56. package/dist/src/commands/context/context-show.command.js +64 -0
  57. package/dist/src/commands/context/context-show.command.js.map +1 -0
  58. package/dist/src/commands/context/context-use.command.d.ts +9 -0
  59. package/dist/src/commands/context/context-use.command.js +45 -0
  60. package/dist/src/commands/context/context-use.command.js.map +1 -0
  61. package/dist/src/commands/context/context.command.d.ts +5 -0
  62. package/dist/src/commands/context/context.command.js +43 -0
  63. package/dist/src/commands/context/context.command.js.map +1 -0
  64. package/dist/src/commands/endpoint/endpoint-ensure.command.d.ts +18 -0
  65. package/dist/src/commands/endpoint/endpoint-ensure.command.js +90 -0
  66. package/dist/src/commands/endpoint/endpoint-ensure.command.js.map +1 -0
  67. package/dist/src/commands/endpoint/endpoint-list.command.d.ts +14 -0
  68. package/dist/src/commands/endpoint/endpoint-list.command.js +62 -0
  69. package/dist/src/commands/endpoint/endpoint-list.command.js.map +1 -0
  70. package/dist/src/commands/endpoint/endpoint.command.d.ts +5 -0
  71. package/dist/src/commands/endpoint/endpoint.command.js +34 -0
  72. package/dist/src/commands/endpoint/endpoint.command.js.map +1 -0
  73. package/dist/src/commands/example/example-bootstrap.command.d.ts +24 -0
  74. package/dist/src/commands/example/example-bootstrap.command.js +133 -0
  75. package/dist/src/commands/example/example-bootstrap.command.js.map +1 -0
  76. package/dist/src/commands/example/example.command.d.ts +5 -0
  77. package/dist/src/commands/example/example.command.js +33 -0
  78. package/dist/src/commands/example/example.command.js.map +1 -0
  79. package/dist/src/commands/instance/instance-add.command.d.ts +16 -0
  80. package/dist/src/commands/instance/instance-add.command.js +83 -0
  81. package/dist/src/commands/instance/instance-add.command.js.map +1 -0
  82. package/dist/src/commands/instance/instance-list.command.d.ts +9 -0
  83. package/dist/src/commands/instance/instance-list.command.js +45 -0
  84. package/dist/src/commands/instance/instance-list.command.js.map +1 -0
  85. package/dist/src/commands/instance/instance-remove.command.d.ts +9 -0
  86. package/dist/src/commands/instance/instance-remove.command.js +54 -0
  87. package/dist/src/commands/instance/instance-remove.command.js.map +1 -0
  88. package/dist/src/commands/instance/instance-show.command.d.ts +9 -0
  89. package/dist/src/commands/instance/instance-show.command.js +50 -0
  90. package/dist/src/commands/instance/instance-show.command.js.map +1 -0
  91. package/dist/src/commands/instance/instance.command.d.ts +5 -0
  92. package/dist/src/commands/instance/instance.command.js +41 -0
  93. package/dist/src/commands/instance/instance.command.js.map +1 -0
  94. package/dist/src/commands/migration/apply-migrations.command.js +1 -0
  95. package/dist/src/commands/migration/apply-migrations.command.js.map +1 -1
  96. package/dist/src/commands/project/project-ensure.command.d.ts +16 -0
  97. package/dist/src/commands/project/project-ensure.command.js +71 -0
  98. package/dist/src/commands/project/project-ensure.command.js.map +1 -0
  99. package/dist/src/commands/project/project.command.d.ts +5 -0
  100. package/dist/src/commands/project/project.command.js +33 -0
  101. package/dist/src/commands/project/project.command.js.map +1 -0
  102. package/dist/src/services/bootstrap/bootstrap.service.d.ts +112 -0
  103. package/dist/src/services/bootstrap/bootstrap.service.js +438 -0
  104. package/dist/src/services/bootstrap/bootstrap.service.js.map +1 -0
  105. package/dist/src/services/bootstrap/index.d.ts +1 -0
  106. package/dist/src/services/bootstrap/index.js +6 -0
  107. package/dist/src/services/bootstrap/index.js.map +1 -0
  108. package/dist/src/services/connection/api-client.d.ts +4 -0
  109. package/dist/src/services/connection/api-client.js +67 -5
  110. package/dist/src/services/connection/api-client.js.map +1 -1
  111. package/dist/src/services/connection/connection-factory.service.js +4 -3
  112. package/dist/src/services/connection/connection-factory.service.js.map +1 -1
  113. package/dist/src/services/connection/connection.service.d.ts +10 -2
  114. package/dist/src/services/connection/connection.service.js +49 -4
  115. package/dist/src/services/connection/connection.service.js.map +1 -1
  116. package/dist/src/services/credentials/credential-store.service.d.ts +19 -0
  117. package/dist/src/services/credentials/credential-store.service.js +112 -0
  118. package/dist/src/services/credentials/credential-store.service.js.map +1 -0
  119. package/dist/src/services/credentials/credential-target.service.d.ts +22 -0
  120. package/dist/src/services/credentials/credential-target.service.js +107 -0
  121. package/dist/src/services/credentials/credential-target.service.js.map +1 -0
  122. package/dist/src/services/credentials/index.d.ts +2 -0
  123. package/dist/src/services/credentials/index.js +8 -0
  124. package/dist/src/services/credentials/index.js.map +1 -0
  125. package/dist/src/services/index.d.ts +3 -0
  126. package/dist/src/services/index.js +3 -0
  127. package/dist/src/services/index.js.map +1 -1
  128. package/dist/src/services/url/auth-prompt.service.d.ts +3 -1
  129. package/dist/src/services/url/auth-prompt.service.js +3 -0
  130. package/dist/src/services/url/auth-prompt.service.js.map +1 -1
  131. package/dist/src/services/url/url-builder.service.js +3 -0
  132. package/dist/src/services/url/url-builder.service.js.map +1 -1
  133. package/dist/src/services/url/url-parser.service.d.ts +1 -1
  134. package/dist/src/services/url/url-parser.service.js +12 -6
  135. package/dist/src/services/url/url-parser.service.js.map +1 -1
  136. package/dist/src/services/workspace/index.d.ts +1 -0
  137. package/dist/src/services/workspace/index.js +11 -0
  138. package/dist/src/services/workspace/index.js.map +1 -0
  139. package/dist/src/services/workspace/workspace-config.service.d.ts +66 -0
  140. package/dist/src/services/workspace/workspace-config.service.js +313 -0
  141. package/dist/src/services/workspace/workspace-config.service.js.map +1 -0
  142. package/dist/tsconfig.build.tsbuildinfo +1 -1
  143. package/docs/authentication.md +69 -26
  144. package/docs/bootstrap-commands.md +105 -0
  145. package/docs/configuration.md +49 -22
  146. package/docs/url-format.md +27 -2
  147. package/docs/workspace-config.md +135 -0
  148. package/e2e/jest-matrix.json +14 -0
  149. package/e2e/matrix/M01-auth-commands.e2e-spec.ts +241 -0
  150. package/e2e/matrix/M02-instance-commands.e2e-spec.ts +213 -0
  151. package/e2e/matrix/M03-context-commands.e2e-spec.ts +279 -0
  152. package/e2e/matrix/M04-project-ensure.e2e-spec.ts +218 -0
  153. package/e2e/matrix/M05-endpoint-commands.e2e-spec.ts +172 -0
  154. package/e2e/matrix/M06-example-bootstrap.e2e-spec.ts +437 -0
  155. package/e2e/matrix/M07-migrate.e2e-spec.ts +229 -0
  156. package/e2e/matrix/M08-schema.e2e-spec.ts +163 -0
  157. package/e2e/matrix/M09-rows.e2e-spec.ts +185 -0
  158. package/e2e/matrix/M10-sync.e2e-spec.ts +224 -0
  159. package/e2e/matrix/M11-target-resolution.e2e-spec.ts +177 -0
  160. package/e2e/matrix/M12-error-paths.e2e-spec.ts +162 -0
  161. package/e2e/matrix/M13-no-auth-mode.e2e-spec.ts +119 -0
  162. package/e2e/matrix/M14-multi-instance-workspace.e2e-spec.ts +182 -0
  163. package/e2e/matrix/README.md +354 -0
  164. package/e2e/tests/01-auth.e2e-spec.ts +19 -0
  165. package/e2e/tests/07-workspace-config.e2e-spec.ts +492 -0
  166. package/e2e/tests/08-bootstrap.e2e-spec.ts +304 -0
  167. package/e2e/utils/cli-runner.ts +27 -8
  168. package/e2e/utils/matrix-fixtures.ts +141 -0
  169. package/e2e/utils/matrix-workspace.ts +106 -0
  170. package/e2e/utils/standalone-api.ts +314 -0
  171. package/e2e/utils/standalone-runner.ts +276 -0
  172. package/package.json +6 -3
  173. package/src/app.module.ts +54 -0
  174. package/src/commands/auth/__tests__/auth-command.utils.spec.ts +41 -0
  175. package/src/commands/auth/__tests__/auth-login.command.spec.ts +131 -0
  176. package/src/commands/auth/__tests__/auth-logout.command.spec.ts +85 -0
  177. package/src/commands/auth/__tests__/auth-status.command.spec.ts +106 -0
  178. package/src/commands/auth/__tests__/auth.command.spec.ts +18 -0
  179. package/src/commands/auth/auth-command.utils.ts +36 -0
  180. package/src/commands/auth/auth-login.command.ts +146 -0
  181. package/src/commands/auth/auth-logout.command.ts +71 -0
  182. package/src/commands/auth/auth-status.command.ts +89 -0
  183. package/src/commands/auth/auth.command.ts +20 -0
  184. package/src/commands/base-sync.command.ts +2 -3
  185. package/src/commands/base.command.ts +11 -0
  186. package/src/commands/context/context-create.command.ts +173 -0
  187. package/src/commands/context/context-list.command.ts +36 -0
  188. package/src/commands/context/context-remove.command.ts +35 -0
  189. package/src/commands/context/context-show.command.ts +59 -0
  190. package/src/commands/context/context-use.command.ts +31 -0
  191. package/src/commands/context/context.command.ts +28 -0
  192. package/src/commands/endpoint/__tests__/endpoint-ensure.command.spec.ts +129 -0
  193. package/src/commands/endpoint/__tests__/endpoint-list.command.spec.ts +59 -0
  194. package/src/commands/endpoint/__tests__/endpoint.command.spec.ts +14 -0
  195. package/src/commands/endpoint/endpoint-ensure.command.ts +74 -0
  196. package/src/commands/endpoint/endpoint-list.command.ts +48 -0
  197. package/src/commands/endpoint/endpoint.command.ts +19 -0
  198. package/src/commands/example/__tests__/example-bootstrap.command.spec.ts +145 -0
  199. package/src/commands/example/__tests__/example.command.spec.ts +14 -0
  200. package/src/commands/example/example-bootstrap.command.ts +122 -0
  201. package/src/commands/example/example.command.ts +18 -0
  202. package/src/commands/instance/instance-add.command.ts +72 -0
  203. package/src/commands/instance/instance-list.command.ts +31 -0
  204. package/src/commands/instance/instance-remove.command.ts +44 -0
  205. package/src/commands/instance/instance-show.command.ts +35 -0
  206. package/src/commands/instance/instance.command.ts +26 -0
  207. package/src/commands/migration/apply-migrations.command.ts +1 -0
  208. package/src/commands/project/__tests__/project-ensure.command.spec.ts +119 -0
  209. package/src/commands/project/__tests__/project.command.spec.ts +14 -0
  210. package/src/commands/project/project-ensure.command.ts +61 -0
  211. package/src/commands/project/project.command.ts +18 -0
  212. package/src/services/bootstrap/__tests__/bootstrap.service.spec.ts +798 -0
  213. package/src/services/bootstrap/bootstrap.service.ts +698 -0
  214. package/src/services/bootstrap/index.ts +12 -0
  215. package/src/services/connection/__tests__/api-client.spec.ts +262 -0
  216. package/src/services/connection/__tests__/connection-factory.service.spec.ts +2 -0
  217. package/src/services/connection/__tests__/connection.service.spec.ts +161 -0
  218. package/src/services/connection/api-client.ts +88 -5
  219. package/src/services/connection/connection-factory.service.ts +4 -3
  220. package/src/services/connection/connection.service.ts +74 -3
  221. package/src/services/credentials/__tests__/credential-store.service.spec.ts +128 -0
  222. package/src/services/credentials/__tests__/credential-target.service.spec.ts +126 -0
  223. package/src/services/credentials/credential-store.service.ts +145 -0
  224. package/src/services/credentials/credential-target.service.ts +145 -0
  225. package/src/services/credentials/index.ts +9 -0
  226. package/src/services/index.ts +3 -0
  227. package/src/services/url/__tests__/url-builder.service.spec.ts +17 -0
  228. package/src/services/url/__tests__/url-parser.service.spec.ts +162 -0
  229. package/src/services/url/auth-prompt.service.ts +6 -1
  230. package/src/services/url/url-builder.service.ts +4 -0
  231. package/src/services/url/url-parser.service.ts +21 -6
  232. package/src/services/workspace/__tests__/workspace-config.service.spec.ts +378 -0
  233. package/src/services/workspace/index.ts +14 -0
  234. package/src/services/workspace/workspace-config.service.ts +467 -0
  235. package/.github/workflows/bump-version.yml +0 -86
@@ -5,12 +5,15 @@ import {
5
5
  ConnectionFactoryService,
6
6
  ConnectionInfo,
7
7
  } from './connection-factory.service';
8
- import { UrlBuilderService, UrlEnvConfig } from '../url';
8
+ import { LoggerService } from '../common';
9
+ import { RevisiumUrlComplete, UrlBuilderService, UrlEnvConfig } from '../url';
10
+ import { LoadedWorkspaceConfig, WorkspaceConfigService } from '../workspace';
9
11
 
10
12
  export { ConnectionInfo } from './connection-factory.service';
11
13
 
12
14
  export interface ConnectionOptions {
13
15
  url?: string;
16
+ context?: string;
14
17
  createProject?: boolean;
15
18
  }
16
19
 
@@ -22,6 +25,8 @@ export class ConnectionService {
22
25
  private readonly configService: ConfigService,
23
26
  private readonly urlBuilder: UrlBuilderService,
24
27
  private readonly connectionFactory: ConnectionFactoryService,
28
+ private readonly workspaceConfig: WorkspaceConfigService,
29
+ private readonly logger: LoggerService,
25
30
  ) {}
26
31
 
27
32
  public get connection(): ConnectionInfo {
@@ -36,14 +41,80 @@ export class ConnectionService {
36
41
  }
37
42
 
38
43
  public async connect(options: ConnectionOptions = {}): Promise<void> {
39
- const env = this.getEnvConfig();
40
- const url = await this.urlBuilder.parseAndComplete(options.url, 'api', env);
44
+ const url = await this.resolveTarget(options);
41
45
 
42
46
  this._connection = await this.connectionFactory.createConnection(url, {
43
47
  createProject: options.createProject,
44
48
  });
45
49
  }
46
50
 
51
+ public async resolveTarget(
52
+ options: ConnectionOptions = {},
53
+ ): Promise<RevisiumUrlComplete> {
54
+ return this.resolveUrl(options, this.getEnvConfig());
55
+ }
56
+
57
+ private async resolveUrl(
58
+ options: ConnectionOptions,
59
+ env: UrlEnvConfig,
60
+ ): Promise<RevisiumUrlComplete> {
61
+ if (options.url) {
62
+ return this.urlBuilder.parseAndComplete(options.url, 'api', env);
63
+ }
64
+
65
+ if (options.context) {
66
+ const workspace = await this.workspaceConfig.load();
67
+ if (!workspace) {
68
+ throw new Error(
69
+ `No Revisium workspace config found for context "${options.context}". Run: revisium instance add <instance> --url <revisium-server-url>, then revisium context create ${options.context} --instance <instance> --org <org> --project <project>`,
70
+ );
71
+ }
72
+ const url = this.workspaceConfig.resolveConnection(
73
+ workspace,
74
+ options.context,
75
+ env,
76
+ );
77
+ this.logWorkspaceContext(workspace, options.context);
78
+ return url;
79
+ }
80
+
81
+ if (env.url) {
82
+ return this.urlBuilder.parseAndComplete(undefined, 'api', env);
83
+ }
84
+
85
+ const workspace = await this.workspaceConfig.load();
86
+ if (workspace) {
87
+ const url = this.workspaceConfig.resolveConnection(
88
+ workspace,
89
+ undefined,
90
+ env,
91
+ );
92
+ this.logWorkspaceContext(workspace);
93
+ return url;
94
+ }
95
+
96
+ return this.urlBuilder.parseAndComplete(undefined, 'api', env);
97
+ }
98
+
99
+ private logWorkspaceContext(
100
+ loaded: LoadedWorkspaceConfig,
101
+ contextName?: string,
102
+ ): void {
103
+ const selectedContext = contextName || loaded.config.currentContext;
104
+ if (!selectedContext) {
105
+ return;
106
+ }
107
+
108
+ const context = loaded.config.contexts[selectedContext];
109
+ if (!context) {
110
+ return;
111
+ }
112
+
113
+ this.logger.info(
114
+ `Using context ${selectedContext} (instance: ${context.instance})`,
115
+ );
116
+ }
117
+
47
118
  private getEnvConfig(): UrlEnvConfig {
48
119
  return {
49
120
  url: this.configService.get<string>('REVISIUM_URL'),
@@ -0,0 +1,128 @@
1
+ import { CredentialStoreService } from '../credential-store.service';
2
+
3
+ const keyringEntries = new Map<string, string>();
4
+
5
+ jest.mock('@napi-rs/keyring', () => ({
6
+ Entry: jest.fn().mockImplementation((service: string, account: string) => ({
7
+ setPassword: jest.fn((password: string) => {
8
+ keyringEntries.set(`${service}:${account}`, password);
9
+ }),
10
+ getPassword: jest.fn(() => {
11
+ if (account.includes('credential:missing-code')) {
12
+ const error = new Error('mock missing credential');
13
+ Object.assign(error, { code: 'NoEntry' });
14
+ throw error;
15
+ }
16
+
17
+ if (account.includes('credential:missing-name')) {
18
+ const error = new Error('mock missing credential');
19
+ error.name = 'NoEntry';
20
+ throw error;
21
+ }
22
+
23
+ return keyringEntries.get(`${service}:${account}`) ?? null;
24
+ }),
25
+ deleteCredential: jest.fn(() => {
26
+ if (account.includes('credential:missing-code')) {
27
+ const error = new Error('mock missing credential');
28
+ Object.assign(error, { code: 'NoEntry' });
29
+ throw error;
30
+ }
31
+
32
+ return keyringEntries.delete(`${service}:${account}`);
33
+ }),
34
+ })),
35
+ }));
36
+
37
+ describe('CredentialStoreService', () => {
38
+ let service: CredentialStoreService;
39
+ const originalServiceName = process.env.REVISIUM_CREDENTIAL_STORE_SERVICE;
40
+
41
+ beforeEach(() => {
42
+ delete process.env.REVISIUM_CREDENTIAL_STORE_SERVICE;
43
+ keyringEntries.clear();
44
+ service = new CredentialStoreService();
45
+ });
46
+
47
+ afterAll(() => {
48
+ if (originalServiceName === undefined) {
49
+ delete process.env.REVISIUM_CREDENTIAL_STORE_SERVICE;
50
+ return;
51
+ }
52
+ process.env.REVISIUM_CREDENTIAL_STORE_SERVICE = originalServiceName;
53
+ });
54
+
55
+ it('saves, reads, and deletes API key credentials by base URL and credential name', () => {
56
+ const ref = {
57
+ baseUrl: 'https://cloud.revisium.io',
58
+ credential: 'admin',
59
+ };
60
+
61
+ service.saveApiKey(ref, ' rev_saved ');
62
+
63
+ expect(service.hasCredential(ref)).toBe(true);
64
+ expect(service.getCredential(ref)).toEqual({
65
+ method: 'apikey',
66
+ apikey: 'rev_saved',
67
+ });
68
+ expect(service.deleteCredential(ref)).toBe(true);
69
+ expect(service.getCredential(ref)).toBeUndefined();
70
+ });
71
+
72
+ it('uses normalized base URL and credential name in the account key', () => {
73
+ expect(
74
+ service.getAccountName({
75
+ baseUrl: 'https://cloud.revisium.io',
76
+ credential: 'admin',
77
+ }),
78
+ ).toBe('instance:https://cloud.revisium.io|credential:admin');
79
+ });
80
+
81
+ it('rejects empty API keys before writing to the store', () => {
82
+ expect(() =>
83
+ service.saveApiKey(
84
+ {
85
+ baseUrl: 'https://cloud.revisium.io',
86
+ credential: 'default',
87
+ },
88
+ ' ',
89
+ ),
90
+ ).toThrow('API key cannot be empty');
91
+ });
92
+
93
+ it('fails clearly when a saved credential has unsupported JSON', () => {
94
+ const ref = {
95
+ baseUrl: 'https://cloud.revisium.io',
96
+ credential: 'default',
97
+ };
98
+ keyringEntries.set(
99
+ `revisium-cli:${service.getAccountName(ref)}`,
100
+ JSON.stringify({ method: 'token', token: 'jwt' }),
101
+ );
102
+
103
+ expect(() => service.getCredential(ref)).toThrow(
104
+ 'Saved Revisium credential "default" for https://cloud.revisium.io has an unsupported format',
105
+ );
106
+ });
107
+
108
+ it('treats keyring NoEntry errors as missing credentials', () => {
109
+ expect(
110
+ service.getCredential({
111
+ baseUrl: 'https://cloud.revisium.io',
112
+ credential: 'missing-code',
113
+ }),
114
+ ).toBeUndefined();
115
+ expect(
116
+ service.getCredential({
117
+ baseUrl: 'https://cloud.revisium.io',
118
+ credential: 'missing-name',
119
+ }),
120
+ ).toBeUndefined();
121
+ expect(
122
+ service.deleteCredential({
123
+ baseUrl: 'https://cloud.revisium.io',
124
+ credential: 'missing-code',
125
+ }),
126
+ ).toBe(false);
127
+ });
128
+ });
@@ -0,0 +1,126 @@
1
+ import { CredentialTargetService } from '../credential-target.service';
2
+ import { WorkspaceConfigService } from '../../workspace';
3
+
4
+ describe('CredentialTargetService', () => {
5
+ let workspaceConfig: {
6
+ load: jest.Mock;
7
+ normalizeBaseUrl: jest.Mock;
8
+ findInstanceNameByBaseUrl: jest.Mock;
9
+ };
10
+ let service: CredentialTargetService;
11
+
12
+ beforeEach(() => {
13
+ workspaceConfig = {
14
+ load: jest.fn(),
15
+ normalizeBaseUrl: jest.fn(),
16
+ findInstanceNameByBaseUrl: jest.fn(),
17
+ };
18
+ service = new CredentialTargetService(
19
+ workspaceConfig as unknown as WorkspaceConfigService,
20
+ );
21
+ });
22
+
23
+ it('resolves explicit instance targets from workspace config', async () => {
24
+ workspaceConfig.load.mockResolvedValue({
25
+ path: '/repo/.revisium/revisium-cli.config.json',
26
+ config: {
27
+ version: 1,
28
+ currentContext: 'dictionary',
29
+ instances: {
30
+ cloud: {
31
+ baseUrl: 'https://cloud.revisium.io',
32
+ authMode: 'stored',
33
+ },
34
+ },
35
+ contexts: {},
36
+ },
37
+ });
38
+
39
+ await expect(
40
+ service.resolveRequired({
41
+ instance: 'cloud',
42
+ credential: 'admin',
43
+ }),
44
+ ).resolves.toEqual({
45
+ baseUrl: 'https://cloud.revisium.io',
46
+ credential: 'admin',
47
+ instanceName: 'cloud',
48
+ authMode: 'stored',
49
+ configPath: '/repo/.revisium/revisium-cli.config.json',
50
+ });
51
+ });
52
+
53
+ it('resolves URL targets and attaches matching instance metadata when available', async () => {
54
+ workspaceConfig.normalizeBaseUrl.mockReturnValue(
55
+ 'https://cloud.revisium.io',
56
+ );
57
+ workspaceConfig.findInstanceNameByBaseUrl.mockReturnValue('cloud');
58
+ workspaceConfig.load.mockResolvedValue({
59
+ path: '/repo/.revisium/revisium-cli.config.json',
60
+ config: {
61
+ version: 1,
62
+ instances: {
63
+ cloud: {
64
+ baseUrl: 'https://cloud.revisium.io',
65
+ authMode: 'none',
66
+ },
67
+ },
68
+ contexts: {},
69
+ },
70
+ });
71
+
72
+ await expect(
73
+ service.resolveRequired({
74
+ url: 'revisium://cloud.revisium.io/admin/dictionary/master',
75
+ }),
76
+ ).resolves.toEqual({
77
+ baseUrl: 'https://cloud.revisium.io',
78
+ credential: 'default',
79
+ instanceName: 'cloud',
80
+ authMode: 'none',
81
+ configPath: '/repo/.revisium/revisium-cli.config.json',
82
+ });
83
+ });
84
+
85
+ it('uses the current context when status/logout omit target selectors', async () => {
86
+ workspaceConfig.load.mockResolvedValue({
87
+ path: '/repo/.revisium/revisium-cli.config.json',
88
+ config: {
89
+ version: 1,
90
+ currentContext: 'dictionary',
91
+ instances: {
92
+ cloud: {
93
+ baseUrl: 'https://cloud.revisium.io',
94
+ authMode: 'stored',
95
+ },
96
+ },
97
+ contexts: {
98
+ dictionary: {
99
+ instance: 'cloud',
100
+ credential: 'admin',
101
+ organization: 'admin',
102
+ project: 'dictionary',
103
+ },
104
+ },
105
+ },
106
+ });
107
+
108
+ await expect(service.resolveOrCurrentContext({})).resolves.toEqual({
109
+ baseUrl: 'https://cloud.revisium.io',
110
+ credential: 'admin',
111
+ instanceName: 'cloud',
112
+ contextName: 'dictionary',
113
+ authMode: 'stored',
114
+ configPath: '/repo/.revisium/revisium-cli.config.json',
115
+ });
116
+ });
117
+
118
+ it('rejects ambiguous target selectors', async () => {
119
+ await expect(
120
+ service.resolveRequired({
121
+ url: 'revisium://cloud.revisium.io',
122
+ instance: 'cloud',
123
+ }),
124
+ ).rejects.toThrow('Use only one target selector: --instance or --url');
125
+ });
126
+ });
@@ -0,0 +1,145 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { Entry } from '@napi-rs/keyring';
3
+ import { AuthCredentials } from 'src/services/url';
4
+
5
+ const SERVICE_NAME = 'revisium-cli';
6
+
7
+ export interface CredentialRef {
8
+ baseUrl: string;
9
+ credential: string;
10
+ }
11
+
12
+ interface StoredApiKeyCredential {
13
+ method: 'apikey';
14
+ apiKey: string;
15
+ }
16
+
17
+ type StoredCredential = StoredApiKeyCredential;
18
+
19
+ @Injectable()
20
+ export class CredentialStoreService {
21
+ private readonly serviceName =
22
+ process.env.REVISIUM_CREDENTIAL_STORE_SERVICE || SERVICE_NAME;
23
+
24
+ saveApiKey(ref: CredentialRef, apiKey: string): void {
25
+ const trimmedApiKey = apiKey.trim();
26
+ if (!trimmedApiKey) {
27
+ throw new Error('API key cannot be empty');
28
+ }
29
+
30
+ this.writeCredential(ref, {
31
+ method: 'apikey',
32
+ apiKey: trimmedApiKey,
33
+ });
34
+ }
35
+
36
+ getCredential(ref: CredentialRef): AuthCredentials | undefined {
37
+ const raw = this.readCredential(ref);
38
+ if (!raw) {
39
+ return undefined;
40
+ }
41
+
42
+ const stored = this.parseStoredCredential(raw, ref);
43
+ return { method: 'apikey', apikey: stored.apiKey };
44
+ }
45
+
46
+ hasCredential(ref: CredentialRef): boolean {
47
+ return this.readCredential(ref) !== undefined;
48
+ }
49
+
50
+ deleteCredential(ref: CredentialRef): boolean {
51
+ try {
52
+ return this.createEntry(ref).deleteCredential();
53
+ } catch (error) {
54
+ if (this.isMissingCredentialError(error)) {
55
+ return false;
56
+ }
57
+ throw this.wrapStoreError('delete', ref, error);
58
+ }
59
+ }
60
+
61
+ getAccountName(ref: CredentialRef): string {
62
+ return `instance:${ref.baseUrl}|credential:${ref.credential}`;
63
+ }
64
+
65
+ private writeCredential(ref: CredentialRef, credential: StoredCredential) {
66
+ try {
67
+ this.createEntry(ref).setPassword(JSON.stringify(credential));
68
+ } catch (error) {
69
+ throw this.wrapStoreError('save', ref, error);
70
+ }
71
+ }
72
+
73
+ private readCredential(ref: CredentialRef): string | undefined {
74
+ try {
75
+ return this.createEntry(ref).getPassword() ?? undefined;
76
+ } catch (error) {
77
+ if (this.isMissingCredentialError(error)) {
78
+ return undefined;
79
+ }
80
+ throw this.wrapStoreError('read', ref, error);
81
+ }
82
+ }
83
+
84
+ private createEntry(ref: CredentialRef): Entry {
85
+ return new Entry(this.serviceName, this.getAccountName(ref));
86
+ }
87
+
88
+ private parseStoredCredential(
89
+ raw: string,
90
+ ref: CredentialRef,
91
+ ): StoredCredential {
92
+ let parsed: unknown;
93
+ try {
94
+ parsed = JSON.parse(raw);
95
+ } catch {
96
+ throw new Error(
97
+ `Saved Revisium credential "${ref.credential}" for ${ref.baseUrl} is invalid. Run: revisium auth login --url revisium://${ref.baseUrl.replace(/^https?:\/\//, '')} --credential ${ref.credential} --api-key`,
98
+ );
99
+ }
100
+
101
+ if (
102
+ typeof parsed !== 'object' ||
103
+ parsed === null ||
104
+ !('method' in parsed) ||
105
+ parsed.method !== 'apikey' ||
106
+ !('apiKey' in parsed) ||
107
+ typeof parsed.apiKey !== 'string' ||
108
+ parsed.apiKey.trim() === ''
109
+ ) {
110
+ throw new Error(
111
+ `Saved Revisium credential "${ref.credential}" for ${ref.baseUrl} has an unsupported format. Run: revisium auth login --url revisium://${ref.baseUrl.replace(/^https?:\/\//, '')} --credential ${ref.credential} --api-key`,
112
+ );
113
+ }
114
+
115
+ return {
116
+ method: 'apikey',
117
+ apiKey: parsed.apiKey.trim(),
118
+ };
119
+ }
120
+
121
+ private isMissingCredentialError(error: unknown): boolean {
122
+ if (!(error instanceof Error)) {
123
+ return false;
124
+ }
125
+
126
+ const keyringError = error as Error & { code?: unknown };
127
+
128
+ return (
129
+ keyringError.code === 'NoEntry' ||
130
+ error.name === 'NoEntry' ||
131
+ error.message.includes('NoEntry')
132
+ );
133
+ }
134
+
135
+ private wrapStoreError(
136
+ action: 'save' | 'read' | 'delete',
137
+ ref: CredentialRef,
138
+ error: unknown,
139
+ ): Error {
140
+ const message = error instanceof Error ? error.message : String(error);
141
+ return new Error(
142
+ `Could not ${action} Revisium credential "${ref.credential}" for ${ref.baseUrl}: ${message}`,
143
+ );
144
+ }
145
+ }
@@ -0,0 +1,145 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import {
3
+ DEFAULT_CREDENTIAL,
4
+ WorkspaceAuthMode,
5
+ WorkspaceConfigService,
6
+ } from 'src/services/workspace';
7
+
8
+ export interface CredentialTargetOptions {
9
+ url?: string;
10
+ instance?: string;
11
+ credential?: string;
12
+ }
13
+
14
+ export interface CredentialTarget {
15
+ baseUrl: string;
16
+ credential: string;
17
+ instanceName?: string;
18
+ contextName?: string;
19
+ authMode?: WorkspaceAuthMode;
20
+ configPath?: string;
21
+ }
22
+
23
+ @Injectable()
24
+ export class CredentialTargetService {
25
+ constructor(private readonly workspaceConfig: WorkspaceConfigService) {}
26
+
27
+ async resolveRequired(
28
+ options: CredentialTargetOptions,
29
+ ): Promise<CredentialTarget> {
30
+ if (!options.url && !options.instance) {
31
+ throw new Error('Pass --instance <name> or --url <revisium-url>');
32
+ }
33
+
34
+ return this.resolve(options, false);
35
+ }
36
+
37
+ async resolveOrCurrentContext(
38
+ options: CredentialTargetOptions,
39
+ ): Promise<CredentialTarget> {
40
+ return this.resolve(options, true);
41
+ }
42
+
43
+ private async resolve(
44
+ options: CredentialTargetOptions,
45
+ allowCurrentContext: boolean,
46
+ ): Promise<CredentialTarget> {
47
+ if (options.url && options.instance) {
48
+ throw new Error('Use only one target selector: --instance or --url');
49
+ }
50
+
51
+ if (options.url) {
52
+ return this.resolveUrlTarget(options);
53
+ }
54
+
55
+ const loaded = await this.workspaceConfig.load();
56
+ if (!loaded) {
57
+ throw new Error(
58
+ 'No Revisium workspace config found. Pass --url <revisium-url> or run revisium instance add first.',
59
+ );
60
+ }
61
+
62
+ if (options.instance) {
63
+ const instance = loaded.config.instances[options.instance];
64
+ if (!instance) {
65
+ throw new Error(
66
+ `Revisium instance "${options.instance}" was not found`,
67
+ );
68
+ }
69
+
70
+ return {
71
+ baseUrl: instance.baseUrl,
72
+ credential: options.credential || DEFAULT_CREDENTIAL,
73
+ instanceName: options.instance,
74
+ authMode: instance.authMode || 'stored',
75
+ configPath: loaded.path,
76
+ };
77
+ }
78
+
79
+ if (!allowCurrentContext) {
80
+ throw new Error('Pass --instance <name> or --url <revisium-url>');
81
+ }
82
+
83
+ const contextName = loaded.config.currentContext;
84
+ if (!contextName) {
85
+ throw new Error(
86
+ `No current Revisium context is configured in ${loaded.path}. Run: revisium context use <name>`,
87
+ );
88
+ }
89
+
90
+ const context = loaded.config.contexts[contextName];
91
+ if (!context) {
92
+ throw new Error(`Revisium context "${contextName}" was not found`);
93
+ }
94
+
95
+ const instance = loaded.config.instances[context.instance];
96
+ if (!instance) {
97
+ throw new Error(
98
+ `Revisium instance "${context.instance}" for context "${contextName}" was not found`,
99
+ );
100
+ }
101
+
102
+ return {
103
+ baseUrl: instance.baseUrl,
104
+ credential:
105
+ options.credential || context.credential || DEFAULT_CREDENTIAL,
106
+ instanceName: context.instance,
107
+ contextName,
108
+ authMode: instance.authMode || 'stored',
109
+ configPath: loaded.path,
110
+ };
111
+ }
112
+
113
+ private async resolveUrlTarget(
114
+ options: CredentialTargetOptions,
115
+ ): Promise<CredentialTarget> {
116
+ if (!options.url) {
117
+ throw new Error('Pass --url <revisium-url>');
118
+ }
119
+
120
+ const baseUrl = this.workspaceConfig.normalizeBaseUrl(options.url);
121
+ const loaded = await this.workspaceConfig.load();
122
+ if (!loaded) {
123
+ return {
124
+ baseUrl,
125
+ credential: options.credential || DEFAULT_CREDENTIAL,
126
+ };
127
+ }
128
+
129
+ const instanceName = this.workspaceConfig.findInstanceNameByBaseUrl(
130
+ loaded.config,
131
+ baseUrl,
132
+ );
133
+ const instance = instanceName
134
+ ? loaded.config.instances[instanceName]
135
+ : undefined;
136
+
137
+ return {
138
+ baseUrl,
139
+ credential: options.credential || DEFAULT_CREDENTIAL,
140
+ instanceName,
141
+ authMode: instance?.authMode || 'stored',
142
+ configPath: loaded.path,
143
+ };
144
+ }
145
+ }
@@ -0,0 +1,9 @@
1
+ export {
2
+ CredentialRef,
3
+ CredentialStoreService,
4
+ } from './credential-store.service';
5
+ export {
6
+ CredentialTarget,
7
+ CredentialTargetOptions,
8
+ CredentialTargetService,
9
+ } from './credential-target.service';
@@ -2,3 +2,6 @@ export * from './connection';
2
2
  export * from './url';
3
3
  export * from './sync';
4
4
  export * from './common';
5
+ export * from './workspace';
6
+ export * from './credentials';
7
+ export * from './bootstrap';
@@ -508,6 +508,23 @@ describe('UrlBuilderService', () => {
508
508
  );
509
509
  });
510
510
 
511
+ it('formats URL with no auth', () => {
512
+ const url = {
513
+ baseUrl: 'http://localhost:9222',
514
+ auth: {
515
+ method: 'none' as const,
516
+ },
517
+ organization: 'admin',
518
+ project: 'dictionary',
519
+ branch: 'master',
520
+ revision: 'draft',
521
+ };
522
+
523
+ expect(service.formatAsRevisiumUrl(url)).toBe(
524
+ 'revisium://localhost:9222/admin/dictionary/master',
525
+ );
526
+ });
527
+
511
528
  it('formats URL with revision', () => {
512
529
  const url = {
513
530
  baseUrl: 'https://cloud.revisium.io',