newo 1.2.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/.env.example +18 -0
- package/README.md +79 -0
- package/package.json +49 -0
- package/src/api.js +93 -0
- package/src/auth.js +92 -0
- package/src/cli.js +54 -0
- package/src/fsutil.js +26 -0
- package/src/hash.js +17 -0
- package/src/sync.js +284 -0
package/.env.example
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# NEWO endpoints
|
|
2
|
+
NEWO_BASE_URL=https://app.newo.ai
|
|
3
|
+
|
|
4
|
+
# Project you want to sync
|
|
5
|
+
NEWO_PROJECT_ID=b78188ba-0df0-46a8-8713-f0d7cff0a06e
|
|
6
|
+
|
|
7
|
+
# Auth (choose one)
|
|
8
|
+
# 1) Recommended: API key that can be exchanged for tokens:
|
|
9
|
+
NEWO_API_KEY=put_api_key_here
|
|
10
|
+
|
|
11
|
+
# 2) Optional bootstrap tokens (used if present)
|
|
12
|
+
NEWO_ACCESS_TOKEN=
|
|
13
|
+
NEWO_REFRESH_TOKEN=
|
|
14
|
+
|
|
15
|
+
# Optional: explicit refresh endpoint if your server supports it (else leave blank)
|
|
16
|
+
NEWO_REFRESH_URL=
|
|
17
|
+
# Example:
|
|
18
|
+
# NEWO_REFRESH_URL=https://app.newo.ai/api/v1/auth/token/refresh
|
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# NEWO CLI
|
|
2
|
+
|
|
3
|
+
Mirror NEWO "Project → Agent → Flow → Skills" to local files and back, Git-first.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
```bash
|
|
7
|
+
npm install
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Configure
|
|
11
|
+
```bash
|
|
12
|
+
cp .env.example .env
|
|
13
|
+
# Edit .env with your values
|
|
14
|
+
```
|
|
15
|
+
Required:
|
|
16
|
+
- `NEWO_BASE_URL` (default `https://app.newo.ai`)
|
|
17
|
+
- `NEWO_PROJECT_ID`
|
|
18
|
+
- One of:
|
|
19
|
+
- `NEWO_API_KEY` to exchange for tokens (recommended), or
|
|
20
|
+
- `NEWO_ACCESS_TOKEN` (+ optional `NEWO_REFRESH_TOKEN` and `NEWO_REFRESH_URL`)
|
|
21
|
+
|
|
22
|
+
## Commands
|
|
23
|
+
```bash
|
|
24
|
+
npx newo pull # download project -> ./project
|
|
25
|
+
npx newo status # list modified files
|
|
26
|
+
npx newo push # upload modified *.guidance/*.jinja back to NEWO
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Files are stored as:
|
|
30
|
+
- `./project/<AgentIdn>/<FlowIdn>/<SkillIdn>.guidance` (AI guidance scripts)
|
|
31
|
+
- `./project/<AgentIdn>/<FlowIdn>/<SkillIdn>.jinja` (NSL/Jinja template scripts)
|
|
32
|
+
|
|
33
|
+
Hashes are tracked in `.newo/hashes.json` so only changed files are pushed.
|
|
34
|
+
Project structure is also exported to `flows.yaml` for reference.
|
|
35
|
+
|
|
36
|
+
## Features
|
|
37
|
+
- **Two-way sync**: Pull NEWO projects to local files, push local changes back
|
|
38
|
+
- **Change detection**: SHA256 hashing prevents unnecessary uploads
|
|
39
|
+
- **Multiple file types**: `.guidance` (AI prompts) and `.jinja` (NSL templates)
|
|
40
|
+
- **Project structure export**: Generates `flows.yaml` with complete project metadata
|
|
41
|
+
- **Robust authentication**: API key exchange with automatic token refresh
|
|
42
|
+
- **CI/CD ready**: GitHub Actions workflow included
|
|
43
|
+
|
|
44
|
+
## CI/CD (GitHub Actions)
|
|
45
|
+
Create `.github/workflows/deploy.yml`:
|
|
46
|
+
```yaml
|
|
47
|
+
name: Deploy NEWO Skills
|
|
48
|
+
on:
|
|
49
|
+
push:
|
|
50
|
+
branches: [ main ]
|
|
51
|
+
paths:
|
|
52
|
+
- 'project/**/*.guidance'
|
|
53
|
+
- 'project/**/*.jinja'
|
|
54
|
+
jobs:
|
|
55
|
+
deploy:
|
|
56
|
+
runs-on: ubuntu-latest
|
|
57
|
+
steps:
|
|
58
|
+
- uses: actions/checkout@v4
|
|
59
|
+
- uses: actions/setup-node@v4
|
|
60
|
+
with:
|
|
61
|
+
node-version: 20
|
|
62
|
+
- run: npm ci
|
|
63
|
+
- run: node ./src/cli.js push
|
|
64
|
+
env:
|
|
65
|
+
NEWO_BASE_URL: https://app.newo.ai
|
|
66
|
+
NEWO_PROJECT_ID: b78188ba-0df0-46a8-8713-f0d7cff0a06e
|
|
67
|
+
NEWO_API_KEY: ${{ secrets.NEWO_API_KEY }}
|
|
68
|
+
# Optional:
|
|
69
|
+
# NEWO_REFRESH_URL: ${{ secrets.NEWO_REFRESH_URL }}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## API Endpoints
|
|
73
|
+
- `GET /api/v1/bff/agents/list?project_id=...` - List project agents
|
|
74
|
+
- `GET /api/v1/designer/flows/{flowId}/skills` - List skills in flow
|
|
75
|
+
- `GET /api/v1/designer/skills/{skillId}` - Get skill content
|
|
76
|
+
- `PUT /api/v1/designer/flows/skills/{skillId}` - Update skill content
|
|
77
|
+
- `GET /api/v1/designer/flows/{flowId}/events` - List flow events (for flows.yaml)
|
|
78
|
+
- `GET /api/v1/designer/flows/{flowId}/states` - List flow states (for flows.yaml)
|
|
79
|
+
- `POST /api/v1/auth/api-key/token` - Exchange API key for access tokens
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "newo",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "NEWO CLI: sync flows/skills between NEWO and local files",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"newo": "./src/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/**/*.js",
|
|
11
|
+
"README.md",
|
|
12
|
+
".env.example"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"newo",
|
|
16
|
+
"cli",
|
|
17
|
+
"ai",
|
|
18
|
+
"agent",
|
|
19
|
+
"automation",
|
|
20
|
+
"sync",
|
|
21
|
+
"local-development"
|
|
22
|
+
],
|
|
23
|
+
"author": "Your Name",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/yourusername/newo-cli.git"
|
|
28
|
+
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/yourusername/newo-cli/issues"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/yourusername/newo-cli#readme",
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"axios": "^1.7.7",
|
|
38
|
+
"dotenv": "^16.4.5",
|
|
39
|
+
"fs-extra": "^11.2.0",
|
|
40
|
+
"js-yaml": "^4.1.0",
|
|
41
|
+
"minimist": "^1.2.8"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"dev": "node ./src/cli.js",
|
|
45
|
+
"pull": "node ./src/cli.js pull",
|
|
46
|
+
"push": "node ./src/cli.js push",
|
|
47
|
+
"status": "node ./src/cli.js status"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import dotenv from 'dotenv';
|
|
3
|
+
import { getValidAccessToken, forceReauth } from './auth.js';
|
|
4
|
+
dotenv.config();
|
|
5
|
+
|
|
6
|
+
const { NEWO_BASE_URL } = process.env;
|
|
7
|
+
|
|
8
|
+
export async function makeClient(verbose = false) {
|
|
9
|
+
let accessToken = await getValidAccessToken();
|
|
10
|
+
if (verbose) console.log('✓ Access token obtained');
|
|
11
|
+
|
|
12
|
+
const client = axios.create({
|
|
13
|
+
baseURL: NEWO_BASE_URL,
|
|
14
|
+
headers: { accept: 'application/json' }
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
client.interceptors.request.use(async (config) => {
|
|
18
|
+
config.headers = config.headers || {};
|
|
19
|
+
config.headers.Authorization = `Bearer ${accessToken}`;
|
|
20
|
+
if (verbose) {
|
|
21
|
+
console.log(`→ ${config.method?.toUpperCase()} ${config.url}`);
|
|
22
|
+
if (config.data) console.log(' Data:', JSON.stringify(config.data, null, 2));
|
|
23
|
+
if (config.params) console.log(' Params:', config.params);
|
|
24
|
+
}
|
|
25
|
+
return config;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
let retried = false;
|
|
29
|
+
client.interceptors.response.use(
|
|
30
|
+
r => {
|
|
31
|
+
if (verbose) {
|
|
32
|
+
console.log(`← ${r.status} ${r.config.method?.toUpperCase()} ${r.config.url}`);
|
|
33
|
+
if (r.data && Object.keys(r.data).length < 20) {
|
|
34
|
+
console.log(' Response:', JSON.stringify(r.data, null, 2));
|
|
35
|
+
} else if (r.data) {
|
|
36
|
+
console.log(` Response: [${typeof r.data}] ${Array.isArray(r.data) ? r.data.length + ' items' : 'large object'}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return r;
|
|
40
|
+
},
|
|
41
|
+
async (error) => {
|
|
42
|
+
const status = error?.response?.status;
|
|
43
|
+
if (verbose) {
|
|
44
|
+
console.log(`← ${status} ${error.config?.method?.toUpperCase()} ${error.config?.url} - ${error.message}`);
|
|
45
|
+
if (error.response?.data) console.log(' Error data:', error.response.data);
|
|
46
|
+
}
|
|
47
|
+
if (status === 401 && !retried) {
|
|
48
|
+
retried = true;
|
|
49
|
+
if (verbose) console.log('🔄 Retrying with fresh token...');
|
|
50
|
+
accessToken = await forceReauth();
|
|
51
|
+
error.config.headers.Authorization = `Bearer ${accessToken}`;
|
|
52
|
+
return client.request(error.config);
|
|
53
|
+
}
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
return client;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function listAgents(client, projectId) {
|
|
62
|
+
const r = await client.get(`/api/v1/bff/agents/list`, { params: { project_id: projectId } });
|
|
63
|
+
return r.data;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function getProjectMeta(client, projectId) {
|
|
67
|
+
const r = await client.get(`/api/v1/designer/projects/by-id/${projectId}`);
|
|
68
|
+
return r.data;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function listFlowSkills(client, flowId) {
|
|
72
|
+
const r = await client.get(`/api/v1/designer/flows/${flowId}/skills`);
|
|
73
|
+
return r.data;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function getSkill(client, skillId) {
|
|
77
|
+
const r = await client.get(`/api/v1/designer/skills/${skillId}`);
|
|
78
|
+
return r.data;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function updateSkill(client, skillObject) {
|
|
82
|
+
await client.put(`/api/v1/designer/flows/skills/${skillObject.id}`, skillObject);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function listFlowEvents(client, flowId) {
|
|
86
|
+
const r = await client.get(`/api/v1/designer/flows/${flowId}/events`);
|
|
87
|
+
return r.data;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function listFlowStates(client, flowId) {
|
|
91
|
+
const r = await client.get(`/api/v1/designer/flows/${flowId}/states`);
|
|
92
|
+
return r.data;
|
|
93
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import axios from 'axios';
|
|
4
|
+
import dotenv from 'dotenv';
|
|
5
|
+
dotenv.config();
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
NEWO_BASE_URL,
|
|
9
|
+
NEWO_API_KEY,
|
|
10
|
+
NEWO_ACCESS_TOKEN,
|
|
11
|
+
NEWO_REFRESH_TOKEN,
|
|
12
|
+
NEWO_REFRESH_URL
|
|
13
|
+
} = process.env;
|
|
14
|
+
|
|
15
|
+
const STATE_DIR = path.join(process.cwd(), '.newo');
|
|
16
|
+
const TOKENS_PATH = path.join(STATE_DIR, 'tokens.json');
|
|
17
|
+
|
|
18
|
+
async function saveTokens(tokens) {
|
|
19
|
+
await fs.ensureDir(STATE_DIR);
|
|
20
|
+
await fs.writeJson(TOKENS_PATH, tokens, { spaces: 2 });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function loadTokens() {
|
|
24
|
+
if (await fs.pathExists(TOKENS_PATH)) {
|
|
25
|
+
return fs.readJson(TOKENS_PATH);
|
|
26
|
+
}
|
|
27
|
+
if (NEWO_ACCESS_TOKEN || NEWO_REFRESH_TOKEN) {
|
|
28
|
+
const t = {
|
|
29
|
+
access_token: NEWO_ACCESS_TOKEN || '',
|
|
30
|
+
refresh_token: NEWO_REFRESH_TOKEN || '',
|
|
31
|
+
expires_at: Date.now() + 10 * 60 * 1000
|
|
32
|
+
};
|
|
33
|
+
await saveTokens(t);
|
|
34
|
+
return t;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isExpired(tokens) {
|
|
40
|
+
if (!tokens?.expires_at) return false;
|
|
41
|
+
return Date.now() >= tokens.expires_at - 10_000;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function exchangeApiKeyForToken() {
|
|
45
|
+
if (!NEWO_API_KEY) throw new Error('NEWO_API_KEY not set. Provide an API key in .env');
|
|
46
|
+
const url = `${NEWO_BASE_URL}/api/v1/auth/api-key/token`;
|
|
47
|
+
const res = await axios.post(url, {}, { headers: { 'x-api-key': NEWO_API_KEY, 'accept': 'application/json' } });
|
|
48
|
+
const data = res.data || {};
|
|
49
|
+
const access = data.access_token || data.token || data.accessToken;
|
|
50
|
+
const refresh = data.refresh_token || data.refreshToken || '';
|
|
51
|
+
const expiresInSec = data.expires_in || data.expiresIn || 3600;
|
|
52
|
+
const tokens = { access_token: access, refresh_token: refresh, expires_at: Date.now() + expiresInSec * 1000 };
|
|
53
|
+
await saveTokens(tokens);
|
|
54
|
+
return tokens;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function refreshWithEndpoint(refreshToken) {
|
|
58
|
+
if (!NEWO_REFRESH_URL) throw new Error('NEWO_REFRESH_URL not set');
|
|
59
|
+
const res = await axios.post(NEWO_REFRESH_URL, { refresh_token: refreshToken }, { headers: { 'accept': 'application/json' } });
|
|
60
|
+
const data = res.data || {};
|
|
61
|
+
const access = data.access_token || data.token || data.accessToken;
|
|
62
|
+
const refresh = data.refresh_token ?? refreshToken;
|
|
63
|
+
const expiresInSec = data.expires_in || 3600;
|
|
64
|
+
const tokens = { access_token: access, refresh_token: refresh, expires_at: Date.now() + expiresInSec * 1000 };
|
|
65
|
+
await saveTokens(tokens);
|
|
66
|
+
return tokens;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function getValidAccessToken() {
|
|
70
|
+
let tokens = await loadTokens();
|
|
71
|
+
if (!tokens || !tokens.access_token) {
|
|
72
|
+
tokens = await exchangeApiKeyForToken();
|
|
73
|
+
return tokens.access_token;
|
|
74
|
+
}
|
|
75
|
+
if (!isExpired(tokens)) return tokens.access_token;
|
|
76
|
+
|
|
77
|
+
if (NEWO_REFRESH_URL && tokens.refresh_token) {
|
|
78
|
+
try {
|
|
79
|
+
tokens = await refreshWithEndpoint(tokens.refresh_token);
|
|
80
|
+
return tokens.access_token;
|
|
81
|
+
} catch (e) {
|
|
82
|
+
console.warn('Refresh failed, falling back to API key exchange…');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
tokens = await exchangeApiKeyForToken();
|
|
86
|
+
return tokens.access_token;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function forceReauth() {
|
|
90
|
+
const tokens = await exchangeApiKeyForToken();
|
|
91
|
+
return tokens.access_token;
|
|
92
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import minimist from 'minimist';
|
|
3
|
+
import dotenv from 'dotenv';
|
|
4
|
+
import { makeClient, getProjectMeta } from './api.js';
|
|
5
|
+
import { pullAll, pushChanged, status } from './sync.js';
|
|
6
|
+
|
|
7
|
+
dotenv.config();
|
|
8
|
+
const { NEWO_PROJECT_ID } = process.env;
|
|
9
|
+
|
|
10
|
+
async function main() {
|
|
11
|
+
const args = minimist(process.argv.slice(2));
|
|
12
|
+
const cmd = args._[0];
|
|
13
|
+
const verbose = args.verbose || args.v;
|
|
14
|
+
|
|
15
|
+
if (!cmd || ['help', '-h', '--help'].includes(cmd)) {
|
|
16
|
+
console.log(`NEWO CLI
|
|
17
|
+
Usage:
|
|
18
|
+
newo pull # download project -> ./project
|
|
19
|
+
newo push # upload modified *.guidance/*.jinja back to NEWO
|
|
20
|
+
newo status # show modified files
|
|
21
|
+
newo meta # get project metadata (debug)
|
|
22
|
+
|
|
23
|
+
Flags:
|
|
24
|
+
--verbose, -v # enable detailed logging
|
|
25
|
+
|
|
26
|
+
Env:
|
|
27
|
+
NEWO_BASE_URL, NEWO_PROJECT_ID, NEWO_API_KEY, NEWO_REFRESH_URL (optional)
|
|
28
|
+
`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const client = await makeClient(verbose);
|
|
33
|
+
|
|
34
|
+
if (cmd === 'pull') {
|
|
35
|
+
if (!NEWO_PROJECT_ID) throw new Error('NEWO_PROJECT_ID is not set in env');
|
|
36
|
+
await pullAll(client, NEWO_PROJECT_ID, verbose);
|
|
37
|
+
} else if (cmd === 'push') {
|
|
38
|
+
await pushChanged(client, verbose);
|
|
39
|
+
} else if (cmd === 'status') {
|
|
40
|
+
await status(verbose);
|
|
41
|
+
} else if (cmd === 'meta') {
|
|
42
|
+
if (!NEWO_PROJECT_ID) throw new Error('NEWO_PROJECT_ID is not set in env');
|
|
43
|
+
const meta = await getProjectMeta(client, NEWO_PROJECT_ID);
|
|
44
|
+
console.log(JSON.stringify(meta, null, 2));
|
|
45
|
+
} else {
|
|
46
|
+
console.error('Unknown command:', cmd);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
main().catch((e) => {
|
|
52
|
+
console.error(e?.response?.data || e);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
});
|
package/src/fsutil.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export const ROOT_DIR = path.join(process.cwd(), 'project');
|
|
5
|
+
export const STATE_DIR = path.join(process.cwd(), '.newo');
|
|
6
|
+
export const MAP_PATH = path.join(STATE_DIR, 'map.json');
|
|
7
|
+
export const HASHES_PATH = path.join(STATE_DIR, 'hashes.json');
|
|
8
|
+
|
|
9
|
+
export async function ensureState() {
|
|
10
|
+
await fs.ensureDir(STATE_DIR);
|
|
11
|
+
await fs.ensureDir(ROOT_DIR);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function skillPath(agentIdn, flowIdn, skillIdn, runnerType = 'guidance') {
|
|
15
|
+
const extension = runnerType === 'nsl' ? '.jinja' : '.guidance';
|
|
16
|
+
return path.join(ROOT_DIR, agentIdn, flowIdn, `${skillIdn}${extension}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function writeFileAtomic(filepath, content) {
|
|
20
|
+
await fs.ensureDir(path.dirname(filepath));
|
|
21
|
+
await fs.writeFile(filepath, content, 'utf8');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function readIfExists(filepath) {
|
|
25
|
+
return (await fs.pathExists(filepath)) ? fs.readFile(filepath, 'utf8') : null;
|
|
26
|
+
}
|
package/src/hash.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { ensureState, HASHES_PATH } from './fsutil.js';
|
|
4
|
+
|
|
5
|
+
export function sha256(str) {
|
|
6
|
+
return crypto.createHash('sha256').update(str, 'utf8').digest('hex');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function loadHashes() {
|
|
10
|
+
await ensureState();
|
|
11
|
+
if (await fs.pathExists(HASHES_PATH)) return fs.readJson(HASHES_PATH);
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function saveHashes(h) {
|
|
16
|
+
await fs.writeJson(HASHES_PATH, h, { spaces: 2 });
|
|
17
|
+
}
|
package/src/sync.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { listAgents, listFlowSkills, updateSkill, listFlowEvents, listFlowStates } from './api.js';
|
|
2
|
+
import { ensureState, skillPath, writeFileAtomic, readIfExists, MAP_PATH } from './fsutil.js';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import { sha256, loadHashes, saveHashes } from './hash.js';
|
|
5
|
+
import yaml from 'js-yaml';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
|
|
8
|
+
export async function pullAll(client, projectId, verbose = false) {
|
|
9
|
+
await ensureState();
|
|
10
|
+
if (verbose) console.log(`🔍 Fetching agents for project ${projectId}...`);
|
|
11
|
+
const agents = await listAgents(client, projectId);
|
|
12
|
+
if (verbose) console.log(`📦 Found ${agents.length} agents`);
|
|
13
|
+
|
|
14
|
+
const idMap = { projectId, agents: {} };
|
|
15
|
+
|
|
16
|
+
for (const agent of agents) {
|
|
17
|
+
const aKey = agent.idn;
|
|
18
|
+
idMap.agents[aKey] = { id: agent.id, flows: {} };
|
|
19
|
+
|
|
20
|
+
for (const flow of agent.flows ?? []) {
|
|
21
|
+
idMap.agents[aKey].flows[flow.idn] = { id: flow.id, skills: {} };
|
|
22
|
+
|
|
23
|
+
const skills = await listFlowSkills(client, flow.id);
|
|
24
|
+
for (const s of skills) {
|
|
25
|
+
const file = skillPath(agent.idn, flow.idn, s.idn, s.runner_type);
|
|
26
|
+
await writeFileAtomic(file, s.prompt_script || '');
|
|
27
|
+
// Store complete skill metadata for push operations
|
|
28
|
+
idMap.agents[aKey].flows[flow.idn].skills[s.idn] = {
|
|
29
|
+
id: s.id,
|
|
30
|
+
title: s.title,
|
|
31
|
+
idn: s.idn,
|
|
32
|
+
runner_type: s.runner_type,
|
|
33
|
+
model: s.model,
|
|
34
|
+
parameters: s.parameters,
|
|
35
|
+
path: s.path
|
|
36
|
+
};
|
|
37
|
+
console.log(`✓ Pulled ${file}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
await fs.writeJson(MAP_PATH, idMap, { spaces: 2 });
|
|
43
|
+
|
|
44
|
+
// Generate flows.yaml
|
|
45
|
+
if (verbose) console.log('📄 Generating flows.yaml...');
|
|
46
|
+
await generateFlowsYaml(client, agents, verbose);
|
|
47
|
+
|
|
48
|
+
const hashes = {};
|
|
49
|
+
for (const [agentIdn, agentObj] of Object.entries(idMap.agents)) {
|
|
50
|
+
for (const [flowIdn, flowObj] of Object.entries(agentObj.flows)) {
|
|
51
|
+
for (const [skillIdn, skillMeta] of Object.entries(flowObj.skills)) {
|
|
52
|
+
const p = skillPath(agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
|
|
53
|
+
const content = await fs.readFile(p, 'utf8');
|
|
54
|
+
hashes[p] = sha256(content);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
await saveHashes(hashes);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function pushChanged(client, verbose = false) {
|
|
62
|
+
await ensureState();
|
|
63
|
+
if (!(await fs.pathExists(MAP_PATH))) {
|
|
64
|
+
throw new Error('Missing .newo/map.json. Run `newo pull` first.');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (verbose) console.log('📋 Loading project mapping...');
|
|
68
|
+
const idMap = await fs.readJson(MAP_PATH);
|
|
69
|
+
if (verbose) console.log('🔍 Loading file hashes...');
|
|
70
|
+
const oldHashes = await loadHashes();
|
|
71
|
+
const newHashes = { ...oldHashes };
|
|
72
|
+
|
|
73
|
+
if (verbose) console.log('🔄 Scanning for changes...');
|
|
74
|
+
let pushed = 0;
|
|
75
|
+
let scanned = 0;
|
|
76
|
+
|
|
77
|
+
for (const [agentIdn, agentObj] of Object.entries(idMap.agents)) {
|
|
78
|
+
if (verbose) console.log(` 📁 Scanning agent: ${agentIdn}`);
|
|
79
|
+
for (const [flowIdn, flowObj] of Object.entries(agentObj.flows)) {
|
|
80
|
+
if (verbose) console.log(` 📁 Scanning flow: ${flowIdn}`);
|
|
81
|
+
for (const [skillIdn, skillMeta] of Object.entries(flowObj.skills)) {
|
|
82
|
+
const p = skillPath(agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
|
|
83
|
+
scanned++;
|
|
84
|
+
if (verbose) console.log(` 📄 Checking: ${p}`);
|
|
85
|
+
|
|
86
|
+
const content = await readIfExists(p);
|
|
87
|
+
if (content === null) {
|
|
88
|
+
if (verbose) console.log(` ⚠️ File not found: ${p}`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const h = sha256(content);
|
|
93
|
+
const oldHash = oldHashes[p];
|
|
94
|
+
if (verbose) {
|
|
95
|
+
console.log(` 🔍 Hash comparison:`);
|
|
96
|
+
console.log(` Old: ${oldHash || 'none'}`);
|
|
97
|
+
console.log(` New: ${h}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (oldHash !== h) {
|
|
101
|
+
if (verbose) console.log(` 🔄 File changed, preparing to push...`);
|
|
102
|
+
|
|
103
|
+
// Create complete skill object with updated prompt_script
|
|
104
|
+
const skillObject = {
|
|
105
|
+
id: skillMeta.id,
|
|
106
|
+
title: skillMeta.title,
|
|
107
|
+
idn: skillMeta.idn,
|
|
108
|
+
prompt_script: content,
|
|
109
|
+
runner_type: skillMeta.runner_type,
|
|
110
|
+
model: skillMeta.model,
|
|
111
|
+
parameters: skillMeta.parameters,
|
|
112
|
+
path: skillMeta.path
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
if (verbose) {
|
|
116
|
+
console.log(` 📤 Pushing skill object:`);
|
|
117
|
+
console.log(` ID: ${skillObject.id}`);
|
|
118
|
+
console.log(` Title: ${skillObject.title}`);
|
|
119
|
+
console.log(` IDN: ${skillObject.idn}`);
|
|
120
|
+
console.log(` Content length: ${content.length} chars`);
|
|
121
|
+
console.log(` Content preview: ${content.substring(0, 100).replace(/\n/g, '\\n')}...`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
await updateSkill(client, skillObject);
|
|
125
|
+
console.log(`↑ Pushed ${p}`);
|
|
126
|
+
newHashes[p] = h;
|
|
127
|
+
pushed++;
|
|
128
|
+
} else if (verbose) {
|
|
129
|
+
console.log(` ✓ No changes`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (verbose) console.log(`🔄 Scanned ${scanned} files, found ${pushed} changes`);
|
|
136
|
+
await saveHashes(newHashes);
|
|
137
|
+
console.log(pushed ? `✅ Push complete. ${pushed} file(s) updated.` : '✅ Nothing to push.');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function status(verbose = false) {
|
|
141
|
+
await ensureState();
|
|
142
|
+
if (!(await fs.pathExists(MAP_PATH))) {
|
|
143
|
+
console.log('No map. Run `newo pull` first.');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (verbose) console.log('📋 Loading project mapping and hashes...');
|
|
148
|
+
const idMap = await fs.readJson(MAP_PATH);
|
|
149
|
+
const hashes = await loadHashes();
|
|
150
|
+
let dirty = 0;
|
|
151
|
+
|
|
152
|
+
for (const [agentIdn, agentObj] of Object.entries(idMap.agents)) {
|
|
153
|
+
if (verbose) console.log(` 📁 Checking agent: ${agentIdn}`);
|
|
154
|
+
for (const [flowIdn, flowObj] of Object.entries(agentObj.flows)) {
|
|
155
|
+
if (verbose) console.log(` 📁 Checking flow: ${flowIdn}`);
|
|
156
|
+
for (const [skillIdn, skillMeta] of Object.entries(flowObj.skills)) {
|
|
157
|
+
const p = skillPath(agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
|
|
158
|
+
const exists = await fs.pathExists(p);
|
|
159
|
+
if (!exists) {
|
|
160
|
+
console.log(`D ${p}`);
|
|
161
|
+
dirty++;
|
|
162
|
+
if (verbose) console.log(` ❌ Deleted: ${p}`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const content = await fs.readFile(p, 'utf8');
|
|
166
|
+
const h = sha256(content);
|
|
167
|
+
const oldHash = hashes[p];
|
|
168
|
+
if (verbose) {
|
|
169
|
+
console.log(` 📄 ${p}`);
|
|
170
|
+
console.log(` Old hash: ${oldHash || 'none'}`);
|
|
171
|
+
console.log(` New hash: ${h}`);
|
|
172
|
+
}
|
|
173
|
+
if (oldHash !== h) {
|
|
174
|
+
console.log(`M ${p}`);
|
|
175
|
+
dirty++;
|
|
176
|
+
if (verbose) console.log(` 🔄 Modified: ${p}`);
|
|
177
|
+
} else if (verbose) {
|
|
178
|
+
console.log(` ✓ Unchanged: ${p}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
console.log(dirty ? `${dirty} changed file(s).` : 'Clean.');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function generateFlowsYaml(client, agents, verbose = false) {
|
|
187
|
+
const flowsData = { flows: [] };
|
|
188
|
+
|
|
189
|
+
for (const agent of agents) {
|
|
190
|
+
if (verbose) console.log(` 📁 Processing agent: ${agent.idn}`);
|
|
191
|
+
|
|
192
|
+
const agentFlows = [];
|
|
193
|
+
|
|
194
|
+
for (const flow of agent.flows ?? []) {
|
|
195
|
+
if (verbose) console.log(` 📄 Processing flow: ${flow.idn}`);
|
|
196
|
+
|
|
197
|
+
// Get skills for this flow
|
|
198
|
+
const skills = await listFlowSkills(client, flow.id);
|
|
199
|
+
const skillsData = skills.map(skill => ({
|
|
200
|
+
idn: skill.idn,
|
|
201
|
+
title: skill.title || "",
|
|
202
|
+
prompt_script: `flows/${flow.idn}/${skill.idn}.${skill.runner_type === 'nsl' ? 'jinja' : 'nsl'}`,
|
|
203
|
+
runner_type: `!enum "RunnerType.${skill.runner_type}"`,
|
|
204
|
+
model: {
|
|
205
|
+
model_idn: skill.model.model_idn,
|
|
206
|
+
provider_idn: skill.model.provider_idn
|
|
207
|
+
},
|
|
208
|
+
parameters: skill.parameters.map(param => ({
|
|
209
|
+
name: param.name,
|
|
210
|
+
default_value: param.default_value || " "
|
|
211
|
+
}))
|
|
212
|
+
}));
|
|
213
|
+
|
|
214
|
+
// Get events for this flow
|
|
215
|
+
let eventsData = [];
|
|
216
|
+
try {
|
|
217
|
+
const events = await listFlowEvents(client, flow.id);
|
|
218
|
+
eventsData = events.map(event => ({
|
|
219
|
+
title: event.description,
|
|
220
|
+
idn: event.idn,
|
|
221
|
+
skill_selector: `!enum "SkillSelector.${event.skill_selector}"`,
|
|
222
|
+
skill_idn: event.skill_idn,
|
|
223
|
+
state_idn: event.state_idn,
|
|
224
|
+
integration_idn: event.integration_idn,
|
|
225
|
+
connector_idn: event.connector_idn,
|
|
226
|
+
interrupt_mode: `!enum "InterruptMode.${event.interrupt_mode}"`
|
|
227
|
+
}));
|
|
228
|
+
if (verbose) console.log(` 📋 Found ${events.length} events`);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (verbose) console.log(` ⚠️ No events found for flow ${flow.idn}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Get state fields for this flow
|
|
234
|
+
let stateFieldsData = [];
|
|
235
|
+
try {
|
|
236
|
+
const states = await listFlowStates(client, flow.id);
|
|
237
|
+
stateFieldsData = states.map(state => ({
|
|
238
|
+
title: state.title,
|
|
239
|
+
idn: state.idn,
|
|
240
|
+
default_value: state.default_value,
|
|
241
|
+
scope: `!enum "StateFieldScope.${state.scope}"`
|
|
242
|
+
}));
|
|
243
|
+
if (verbose) console.log(` 📊 Found ${states.length} state fields`);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (verbose) console.log(` ⚠️ No state fields found for flow ${flow.idn}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
agentFlows.push({
|
|
249
|
+
idn: flow.idn,
|
|
250
|
+
title: flow.title,
|
|
251
|
+
description: flow.description || null,
|
|
252
|
+
default_runner_type: `!enum "RunnerType.${flow.default_runner_type}"`,
|
|
253
|
+
default_provider_idn: flow.default_model.provider_idn,
|
|
254
|
+
default_model_idn: flow.default_model.model_idn,
|
|
255
|
+
skills: skillsData,
|
|
256
|
+
events: eventsData,
|
|
257
|
+
state_fields: stateFieldsData
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
flowsData.flows.push({
|
|
262
|
+
agent_idn: agent.idn,
|
|
263
|
+
agent_description: agent.description,
|
|
264
|
+
agent_flows: agentFlows
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Convert to YAML and write to file with custom enum handling
|
|
269
|
+
let yamlContent = yaml.dump(flowsData, {
|
|
270
|
+
indent: 2,
|
|
271
|
+
lineWidth: -1,
|
|
272
|
+
noRefs: true,
|
|
273
|
+
sortKeys: false,
|
|
274
|
+
quotingType: '"',
|
|
275
|
+
forceQuotes: false
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Post-process to fix enum formatting
|
|
279
|
+
yamlContent = yamlContent.replace(/"(!enum "[^"]+")"/g, '$1');
|
|
280
|
+
|
|
281
|
+
const yamlPath = path.join('flows.yaml');
|
|
282
|
+
await fs.writeFile(yamlPath, yamlContent, 'utf8');
|
|
283
|
+
console.log(`✓ Generated flows.yaml`);
|
|
284
|
+
}
|