@kinotic-ai/kinotic-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.
@@ -0,0 +1,217 @@
1
+ import { ConnectionInfo, Kinotic } from '@kinotic-ai/core';
2
+ import { confirm } from '@inquirer/prompts';
3
+ import open from 'open';
4
+ import pTimeout from 'p-timeout';
5
+ import { WebSocket } from 'ws';
6
+ import { createStateManager } from './state/IStateManager.js';
7
+ /** State key the rotating refresh token is persisted under, keyed by server url. */
8
+ const CREDENTIALS_KEY = 'kinotic-credentials';
9
+ /** Per-request timeout for REST calls to the Kinotic Server. */
10
+ const FETCH_TIMEOUT_MS = 30_000;
11
+ /**
12
+ * CLI authentication against a Kinotic server using the OAuth 2.0 Device Authorization Grant
13
+ * (RFC 8628). {@link login} runs the interactive browser flow once and stores the refresh
14
+ * token; {@link connect} uses that stored token to open an authenticated {@link Kinotic}
15
+ * connection, refreshing the short-lived access token before every (re)connect.
16
+ */
17
+ export class CliAuthenticator {
18
+ server;
19
+ configDir;
20
+ logger;
21
+ refreshToken = null;
22
+ accessToken = null;
23
+ accessTokenExpiresAt = 0;
24
+ /**
25
+ * @param server the server url to authenticate against
26
+ * @param configDir directory the rotating refresh token is persisted in
27
+ * @param logger sink for user-facing progress messages
28
+ */
29
+ constructor(server, configDir, logger) {
30
+ this.server = server;
31
+ this.configDir = configDir;
32
+ this.logger = logger;
33
+ }
34
+ /**
35
+ * Runs the interactive device-authorization login and persists the refresh token, so
36
+ * later {@link connect} calls — this run or a future one — are non-interactive.
37
+ *
38
+ * @return true if login succeeded
39
+ */
40
+ async login() {
41
+ const target = this.parseServer();
42
+ if (target === null) {
43
+ return false;
44
+ }
45
+ const tokens = await this.deviceLogin(target.restBaseUrl);
46
+ if (tokens === null) {
47
+ return false;
48
+ }
49
+ await this.saveRefreshToken(tokens.refresh_token);
50
+ return true;
51
+ }
52
+ /**
53
+ * Opens an authenticated {@link Kinotic} connection using the stored refresh token. Fails
54
+ * fast — with no interactive prompt — when there are no stored credentials.
55
+ *
56
+ * @return true if the connection was established
57
+ */
58
+ async connect() {
59
+ try {
60
+ const target = this.parseServer();
61
+ if (target === null) {
62
+ return false;
63
+ }
64
+ this.refreshToken = await this.loadRefreshToken();
65
+ if (this.refreshToken === null) {
66
+ this.logger.log('Not logged in. Run `kinotic login` first.');
67
+ return false;
68
+ }
69
+ const connectionInfo = new ConnectionInfo();
70
+ connectionInfo.host = target.host;
71
+ connectionInfo.port = target.port;
72
+ connectionInfo.useSSL = target.useSSL;
73
+ // The CLI is a Node client: it attaches the access token as a WebSocket upgrade
74
+ // header. The factory is async so the token is refreshed before each (re)connect.
75
+ connectionInfo.webSocketFactory = async () => {
76
+ const token = await this.freshAccessToken(target.restBaseUrl);
77
+ return new WebSocket(target.wsUrl, {
78
+ headers: { Authorization: 'Bearer ' + token }
79
+ });
80
+ };
81
+ await pTimeout(Kinotic.connect(connectionInfo), {
82
+ milliseconds: 60000,
83
+ message: 'Connection timeout trying to connect to the Kinotic Server'
84
+ });
85
+ return true;
86
+ }
87
+ catch (e) {
88
+ this.logger.log('Could not connect to the Kinotic Server: '
89
+ + (e instanceof Error ? e.message : String(e)));
90
+ return false;
91
+ }
92
+ }
93
+ /**
94
+ * Returns a valid access token, refreshing it — and persisting the rotated refresh token —
95
+ * when it is absent or within 10s of expiry.
96
+ */
97
+ async freshAccessToken(restBaseUrl) {
98
+ if (this.accessToken !== null && Date.now() < this.accessTokenExpiresAt - 10_000) {
99
+ return this.accessToken;
100
+ }
101
+ const res = await fetch(restBaseUrl + '/api/login/device/refresh', {
102
+ method: 'POST',
103
+ headers: { 'Content-Type': 'application/json' },
104
+ body: JSON.stringify({ refresh_token: this.refreshToken }),
105
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
106
+ });
107
+ if (!res.ok) {
108
+ throw new Error('Session expired. Run `kinotic login` again.');
109
+ }
110
+ const tokens = await res.json();
111
+ this.refreshToken = tokens.refresh_token;
112
+ this.accessToken = tokens.access_token;
113
+ this.accessTokenExpiresAt = Date.now() + (tokens.expires_in ?? 60) * 1000;
114
+ await this.saveRefreshToken(this.refreshToken);
115
+ return this.accessToken;
116
+ }
117
+ /** Parses the server url into the host/port the gateway serves both REST and STOMP on. */
118
+ parseServer() {
119
+ const url = new URL(this.server);
120
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
121
+ this.logger.log('Invalid server URL, only http and https are supported');
122
+ return null;
123
+ }
124
+ const useSSL = url.protocol === 'https:';
125
+ // Locally the server url often points at the static web port; the gateway
126
+ // (REST + STOMP) always listens on 58503, so the port is overridden.
127
+ let port;
128
+ if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') {
129
+ port = 58503;
130
+ }
131
+ else if (url.port) {
132
+ port = Number(url.port);
133
+ }
134
+ else {
135
+ port = useSSL ? 443 : 58503;
136
+ }
137
+ return {
138
+ host: url.hostname,
139
+ port,
140
+ useSSL,
141
+ restBaseUrl: (useSSL ? 'https' : 'http') + '://' + url.hostname + ':' + port,
142
+ wsUrl: (useSSL ? 'wss' : 'ws') + '://' + url.hostname + ':' + port + '/v1'
143
+ };
144
+ }
145
+ /** Runs the RFC 8628 device flow: start, browser approval, then poll for tokens. */
146
+ async deviceLogin(restBaseUrl) {
147
+ const startRes = await fetch(restBaseUrl + '/api/login/device/start', {
148
+ method: 'POST',
149
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
150
+ });
151
+ if (!startRes.ok) {
152
+ this.logger.log('Could not start device authorization with the Kinotic Server.');
153
+ return null;
154
+ }
155
+ const start = await startRes.json();
156
+ this.logger.log('Authenticate your account at:');
157
+ this.logger.log(start.verification_uri_complete);
158
+ this.logger.log(`Your code is: ${start.user_code}`);
159
+ const answer = await confirm({ message: 'Open in browser?', default: true });
160
+ if (answer) {
161
+ await open(start.verification_uri_complete);
162
+ }
163
+ const deadline = Date.now() + start.expires_in * 1000;
164
+ let intervalMs = Math.max(start.interval, 1) * 1000;
165
+ while (Date.now() < deadline) {
166
+ await delay(intervalMs);
167
+ const tokenRes = await fetch(restBaseUrl + '/api/login/device/token', {
168
+ method: 'POST',
169
+ headers: { 'Content-Type': 'application/json' },
170
+ body: JSON.stringify({ device_code: start.device_code }),
171
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
172
+ });
173
+ if (tokenRes.ok) {
174
+ return await tokenRes.json();
175
+ }
176
+ const error = await readErrorCode(tokenRes);
177
+ if (error === 'slow_down') {
178
+ intervalMs += 5000;
179
+ }
180
+ else if (error !== 'authorization_pending') {
181
+ this.logger.log(`Device authorization failed: ${error}`);
182
+ return null;
183
+ }
184
+ }
185
+ this.logger.log('Device authorization timed out before it was approved.');
186
+ return null;
187
+ }
188
+ async loadRefreshToken() {
189
+ const stateManager = createStateManager(this.configDir);
190
+ if (!(await stateManager.containsState(CREDENTIALS_KEY))) {
191
+ return null;
192
+ }
193
+ const credentials = await stateManager.load(CREDENTIALS_KEY);
194
+ return credentials[this.server] ?? null;
195
+ }
196
+ async saveRefreshToken(refreshToken) {
197
+ const stateManager = createStateManager(this.configDir);
198
+ let credentials = {};
199
+ if (await stateManager.containsState(CREDENTIALS_KEY)) {
200
+ credentials = await stateManager.load(CREDENTIALS_KEY);
201
+ }
202
+ credentials[this.server] = refreshToken;
203
+ await stateManager.save(CREDENTIALS_KEY, credentials);
204
+ }
205
+ }
206
+ async function readErrorCode(res) {
207
+ try {
208
+ const body = await res.json();
209
+ return body.error ?? 'unknown_error';
210
+ }
211
+ catch {
212
+ return 'unknown_error';
213
+ }
214
+ }
215
+ function delay(ms) {
216
+ return new Promise(resolve => setTimeout(resolve, ms));
217
+ }
@@ -5,23 +5,7 @@ export type GeneratedServiceInfo = {
5
5
  entityServiceName: string;
6
6
  namedQueries: FunctionDefinition[];
7
7
  };
8
- export declare class SessionMetadata {
9
- sessionId: string;
10
- replyToId: string;
11
- constructor(sessionId: string, replyToId: string);
12
- }
13
8
  export declare function jsonStringifyReplacer(key: any, value: any): any;
14
- /**
15
- * Connects to the server and upgrades the session to a CLI session
16
- * Currently this works by connecting and waiting for the clients session id on the event bus
17
- * The cli then disconnects and reconnects using the clients' session.
18
- * This will be replaced when the server supports a session upgrade command
19
- * @param server the server to connect to
20
- * @param logger the logger to use
21
- * @param authHeadersFile path to a file containing the auth headers
22
- * @return true if the session was upgraded successfully
23
- */
24
- export declare function connectAndUpgradeSession(server: string, logger: Logger, authHeadersFile?: string): Promise<boolean>;
25
9
  export type EntityInfo = {
26
10
  exportedFromFile: string;
27
11
  defaultExport: boolean;
@@ -1,24 +1,11 @@
1
- import { Kinotic, Event, EventConstants, ParticipantConstants } from '@kinotic-ai/core';
2
1
  import { ObjectC3Type } from '@kinotic-ai/idl';
3
2
  import fs from 'fs';
4
3
  import fsPromises from 'fs/promises';
5
- import { confirm } from '@inquirer/prompts';
6
- import open from 'open';
7
- import pTimeout from 'p-timeout';
8
4
  import path from 'path';
9
5
  import { IndentationText, Node, Project } from 'ts-morph';
10
- import { v4 as uuidv4 } from 'uuid';
11
6
  import { createConversionContext } from './converter/IConversionContext.js';
12
7
  import { TypescriptConversionState } from './converter/typescript/TypescriptConversionState.js';
13
8
  import { TypescriptConverterStrategy } from './converter/typescript/TypescriptConverterStrategy.js';
14
- export class SessionMetadata {
15
- sessionId;
16
- replyToId;
17
- constructor(sessionId, replyToId) {
18
- this.sessionId = sessionId;
19
- this.replyToId = replyToId;
20
- }
21
- }
22
9
  function isEmpty(value) {
23
10
  if (value === null || value === undefined) {
24
11
  return true;
@@ -36,134 +23,6 @@ export function jsonStringifyReplacer(key, value) {
36
23
  ? undefined
37
24
  : value;
38
25
  }
39
- /**
40
- * Connects to the server and upgrades the session to a CLI session
41
- * Currently this works by connecting and waiting for the clients session id on the event bus
42
- * The cli then disconnects and reconnects using the clients' session.
43
- * This will be replaced when the server supports a session upgrade command
44
- * @param server the server to connect to
45
- * @param logger the logger to use
46
- * @param authHeadersFile path to a file containing the auth headers
47
- * @return true if the session was upgraded successfully
48
- */
49
- export async function connectAndUpgradeSession(server, logger, authHeadersFile) {
50
- try {
51
- const serverURL = new URL(server);
52
- if (serverURL.protocol !== 'http:' && serverURL.protocol !== 'https:') {
53
- logger.log('Invalid server URL, only http and https are supported');
54
- return false;
55
- }
56
- let connectionInfo = { host: '' };
57
- if (serverURL.hostname === 'localhost' || serverURL.hostname === '127.0.0.1') {
58
- connectionInfo.host = serverURL.hostname;
59
- connectionInfo.port = 58503;
60
- }
61
- else {
62
- connectionInfo.host = serverURL.hostname;
63
- if (serverURL.protocol === 'https:') {
64
- connectionInfo.useSSL = true;
65
- connectionInfo.port = 443;
66
- }
67
- if (serverURL.port) {
68
- connectionInfo.port = Number(serverURL.port);
69
- }
70
- else if (!connectionInfo.useSSL) {
71
- connectionInfo.port = 58503;
72
- }
73
- }
74
- if (authHeadersFile) {
75
- if (!fs.existsSync(authHeadersFile)) {
76
- logger.log(`Authentication header file ${authHeadersFile} does not exist. Please provide a valid file.`);
77
- return false;
78
- }
79
- else {
80
- logger.log(`Authentication header file ${authHeadersFile} loaded successfully. Using authentication headers to connect to the Kinotic Server.`);
81
- }
82
- const authHeaders = JSON.parse(fs.readFileSync(authHeadersFile, 'utf8'));
83
- connectionInfo.connectHeaders = authHeaders;
84
- const connectedInfo = await pTimeout(Kinotic.connect(connectionInfo), {
85
- milliseconds: 60000,
86
- message: 'Connection timeout trying to connect to the Kinotic Server'
87
- });
88
- if (connectedInfo) {
89
- return true;
90
- }
91
- else {
92
- return false;
93
- }
94
- }
95
- else {
96
- connectionInfo.connectHeaders = {
97
- login: ParticipantConstants.CLI_PARTICIPANT_ID
98
- };
99
- const connectedInfo = await pTimeout(Kinotic.connect(connectionInfo), {
100
- milliseconds: 60000,
101
- message: 'Connection timeout trying to connect to the Kinotic Server'
102
- });
103
- if (connectedInfo) {
104
- // This works because any client can subscribe to a destination that is scoped to the connectedInfo.replyToId
105
- const scope = connectedInfo.replyToId + ':' + uuidv4();
106
- const url = server + (server.endsWith('/') ? '' : '/') + '#/sessionUpgrade/' + encodeURIComponent(scope);
107
- logger.log('Authenticate your account at:');
108
- logger.log(url);
109
- const answer = await confirm({
110
- message: 'Open in browser?',
111
- default: true,
112
- });
113
- if (answer) {
114
- await open(url);
115
- }
116
- else {
117
- logger.log('Browser will not be opened. You must authenticate your account before continuing.');
118
- }
119
- const sessionMetadata = await receiveSessionId(scope);
120
- await Kinotic.disconnect();
121
- connectionInfo.connectHeaders = {
122
- session: sessionMetadata.sessionId
123
- };
124
- // Provide this so the continuum client will use the same replyToId as the session
125
- connectionInfo.connectHeaders[EventConstants.REPLY_TO_ID_HEADER] = sessionMetadata.replyToId;
126
- await Kinotic.connect(connectionInfo);
127
- logger.log('Authenticated successfully\n');
128
- return true;
129
- }
130
- else {
131
- logger.log("Could not connect to the Kinotic Server. Please check the server is running and the URL is correct.");
132
- return false;
133
- }
134
- }
135
- }
136
- catch (e) {
137
- logger.log("Could not connect to the Kinotic Server. Please check the server is running and the URL is correct.", e);
138
- return false;
139
- }
140
- }
141
- function receiveSessionId(scope) {
142
- const subscribeCRI = EventConstants.SERVICE_DESTINATION_PREFIX + scope + '@continuum.cli.SessionUpgradeService';
143
- return new Promise((resolve, reject) => {
144
- const subscription = Kinotic.eventBus.observe(subscribeCRI).subscribe((value) => {
145
- // send reply to user
146
- const replyTo = value.getHeader(EventConstants.REPLY_TO_HEADER);
147
- if (replyTo) {
148
- const replyEvent = new Event(replyTo);
149
- const correlationId = value.getHeader(EventConstants.CORRELATION_ID_HEADER);
150
- if (correlationId) {
151
- replyEvent.setHeader(EventConstants.CORRELATION_ID_HEADER, correlationId);
152
- }
153
- replyEvent.setHeader(EventConstants.CONTENT_TYPE_HEADER, EventConstants.CONTENT_JSON);
154
- Kinotic.eventBus.send(replyEvent);
155
- }
156
- subscription.unsubscribe();
157
- const jsonObj = JSON.parse(value.getDataString());
158
- if (jsonObj?.length > 0) {
159
- resolve(jsonObj[0]);
160
- }
161
- else {
162
- reject('No Session Id found in data');
163
- }
164
- });
165
- });
166
- }
167
26
  function getEntityDecoratorIfExists(node) {
168
27
  if (Node.isClassDeclaration(node)) {
169
28
  return node.getDecorator('Entity');
@@ -19,7 +19,7 @@ export class Base{{ entityName }}Repository extends EntityRepository<{{ entityNa
19
19
  this.shouldValidate = shouldValidate
20
20
  }
21
21
 
22
- protected async beforeSaveOrUpdate(entity: {{ entityName }}): Promise<{{ entityName }}> {
22
+ protected override async beforeSaveOrUpdate(entity: {{ entityName }}): Promise<{{ entityName }}> {
23
23
  if (this.shouldValidate) {
24
24
  return this.validate(entity)
25
25
  } else {
@@ -27,7 +27,7 @@ export class Base{{ entityName }}Repository extends EntityRepository<{{ entityNa
27
27
  }
28
28
  }
29
29
 
30
- protected async beforeBulkSaveOrUpdate(entities: {{ entityName }}[]): Promise<{{ entityName }}[]> {
30
+ protected override async beforeBulkSaveOrUpdate(entities: {{ entityName }}[]): Promise<{{ entityName }}[]> {
31
31
  if (this.shouldValidate) {
32
32
  const validatedEntities: {{ entityName }}[] = []
33
33
  for (let entity of entities) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kinotic-ai/kinotic-cli",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Kinotic CLI provides the ability to build, deploy, and manage Kinotic applications that run on the Kinotic OS.",
5
5
  "author": "Kinotic Developers",
6
6
  "bin": {
@@ -14,34 +14,27 @@
14
14
  "files": [
15
15
  "/bin",
16
16
  "/dist",
17
- "/npm-shrinkwrap.json",
18
- "/oclif.manifest.json"
17
+ "/npm-shrinkwrap.json"
19
18
  ],
20
19
  "dependencies": {
21
20
  "@inquirer/prompts": "^8.3.2",
22
- "@kinotic-ai/core": "^1.1.1",
21
+ "@kinotic-ai/core": "^1.4.0",
23
22
  "@kinotic-ai/idl": "^1.0.9",
24
- "@kinotic-ai/os-api": "^1.1.0",
25
- "@kinotic-ai/persistence": "^1.2.0",
23
+ "@kinotic-ai/os-api": "^1.5.1",
24
+ "@kinotic-ai/persistence": "^1.2.1",
26
25
  "@oclif/core": "^4.9.0",
27
- "@oclif/plugin-autocomplete": "^3.2.41",
28
- "@oclif/plugin-help": "^6",
29
- "@oclif/plugin-not-found": "^3.2.75",
30
- "@oclif/plugin-plugins": "^5.4.58",
31
- "@oclif/plugin-update": "^4.7.23",
32
- "@oclif/plugin-warn-if-update-available": "^3.1.55",
33
26
  "c12": "^3.3.3",
34
27
  "chalk": "^5.6.2",
35
28
  "execa": "^9.6.1",
36
29
  "glob": "^13.0.6",
37
30
  "graphql": "^16.13.1",
38
- "liquidjs": "10.25.1",
31
+ "liquidjs": "10.25.7",
39
32
  "open": "^11.0.0",
40
33
  "ora": "^9.3.0",
41
34
  "p-timeout": "^7.0.1",
42
35
  "radash": "^12.1.1",
43
36
  "reflect-metadata": "^0.2.2",
44
- "simple-git": "^3.33.0",
37
+ "simple-git": "3.36.0",
45
38
  "terminal-link": "^5.0.0",
46
39
  "ts-morph": "^27.0.2",
47
40
  "uuid": "^13.0.0",
@@ -50,7 +43,6 @@
50
43
  "zod": "^4.3.6"
51
44
  },
52
45
  "devDependencies": {
53
- "@oclif/test": "^4.1.16",
54
46
  "@types/chai": "^5",
55
47
  "@types/inquirer": "^9.0.9",
56
48
  "@types/mocha": "^10.0.10",
@@ -59,10 +51,8 @@
59
51
  "@types/ws": "^8.18.1",
60
52
  "chai": "^6",
61
53
  "eslint": "^9",
62
- "eslint-config-oclif": "^6",
63
54
  "eslint-config-prettier": "^10",
64
55
  "mocha": "^11",
65
- "oclif": "^4.22.92",
66
56
  "shx": "^0.4.0",
67
57
  "tslib": "^2.8.1",
68
58
  "tsx": "^4.21.0",
@@ -73,14 +63,6 @@
73
63
  "bin": "kinotic",
74
64
  "dirname": "kinotic",
75
65
  "commands": "./dist/commands",
76
- "plugins": [
77
- "@oclif/plugin-help",
78
- "@oclif/plugin-plugins",
79
- "@oclif/plugin-update",
80
- "@oclif/plugin-not-found",
81
- "@oclif/plugin-warn-if-update-available",
82
- "@oclif/plugin-autocomplete"
83
- ],
84
66
  "topicSeparator": " ",
85
67
  "topics": {
86
68
  "create": {
@@ -91,16 +73,14 @@
91
73
  "engines": {
92
74
  "node": ">=18.0.0"
93
75
  },
94
- "keywords": [
95
- "oclif"
96
- ],
76
+ "keywords": [],
97
77
  "types": "dist/index.d.ts",
98
78
  "scripts": {
99
79
  "build": "shx rm -rf dist && tsc -b && tsc-alias -f && pnpm run copy-files",
100
80
  "lint": "eslint . --ext .ts --config .eslintrc",
101
81
  "posttest": "pnpm lint",
102
82
  "test": "mocha --forbid-only \"test/**/*.test.ts\"",
103
- "version": "oclif readme && git add README.md",
83
+ "version": "git add README.md",
104
84
  "copy-files": "cp -r ./src/templates/ ./dist/templates/"
105
85
  }
106
86
  }
package/bin/dev.cmd DELETED
@@ -1,3 +0,0 @@
1
- @echo off
2
-
3
- node "%~dp0\dev" %*
package/bin/dev.js DELETED
@@ -1,6 +0,0 @@
1
- #!/usr/bin/env tsx
2
- // eslint-disable-next-line node/shebang
3
- (async () => {
4
- const oclif = await import('@oclif/core')
5
- await oclif.execute({type: 'esm', development: true, dir: import.meta.url})
6
- })()
package/bin/run.cmd DELETED
@@ -1,3 +0,0 @@
1
- @echo off
2
-
3
- node "%~dp0\run" %*
package/bin/run.js DELETED
@@ -1,6 +0,0 @@
1
- #!/usr/bin/env node
2
- // eslint-disable-next-line node/shebang
3
- (async () => {
4
- const oclif = await import('@oclif/core')
5
- await oclif.execute({type: 'esm', dir: import.meta.url})
6
- })()