@sensolus/create-snt-agent-app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +63 -0
- package/package.json +17 -0
- package/template/README.md +26 -0
- package/template/_gitignore +7 -0
- package/template/backend/app.py +70 -0
- package/template/backend/requirements.txt +2 -0
- package/template/eslint.config.js +45 -0
- package/template/index.html +12 -0
- package/template/package.json +25 -0
- package/template/src/App.jsx +38 -0
- package/template/src/i18n/messages.js +17 -0
- package/template/src/main.jsx +15 -0
- package/template/src/styles/app.css +6 -0
- package/template/vite.config.js +19 -0
package/index.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* npm create @sensolus/snt-agent-app my-app
|
|
4
|
+
*
|
|
5
|
+
* Copies the template into ./my-app, substituting {{APP_NAME}}.
|
|
6
|
+
* Files prefixed with _ are renamed to dotfiles (npm publish strips real dotfiles).
|
|
7
|
+
*/
|
|
8
|
+
import { cp, readdir, readFile, writeFile, rename, stat } from 'node:fs/promises'
|
|
9
|
+
import { existsSync } from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
|
|
13
|
+
const templateDir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'template')
|
|
14
|
+
|
|
15
|
+
const rawName = process.argv[2]
|
|
16
|
+
if (!rawName) {
|
|
17
|
+
console.error('Usage: npm create @sensolus/snt-agent-app <app-name>')
|
|
18
|
+
process.exit(1)
|
|
19
|
+
}
|
|
20
|
+
const appName = rawName.toLowerCase().replace(/[^a-z0-9-_]/g, '-')
|
|
21
|
+
const targetDir = path.resolve(process.cwd(), appName)
|
|
22
|
+
|
|
23
|
+
if (existsSync(targetDir)) {
|
|
24
|
+
console.error(`Error: directory ${appName} already exists.`)
|
|
25
|
+
process.exit(1)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
await cp(templateDir, targetDir, { recursive: true })
|
|
29
|
+
|
|
30
|
+
// Rename _gitignore -> .gitignore etc.
|
|
31
|
+
for (const entry of await readdir(targetDir)) {
|
|
32
|
+
if (entry.startsWith('_')) {
|
|
33
|
+
await rename(path.join(targetDir, entry), path.join(targetDir, '.' + entry.slice(1)))
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Substitute {{APP_NAME}} in text files
|
|
38
|
+
async function substitute(dir) {
|
|
39
|
+
for (const entry of await readdir(dir)) {
|
|
40
|
+
const p = path.join(dir, entry)
|
|
41
|
+
if ((await stat(p)).isDirectory()) { await substitute(p); continue }
|
|
42
|
+
if (!/\.(json|js|jsx|html|md|py|txt|sh|css)$|^\.[a-z]+$/i.test(entry)) continue
|
|
43
|
+
const content = await readFile(p, 'utf8')
|
|
44
|
+
if (content.includes('{{APP_NAME}}')) {
|
|
45
|
+
await writeFile(p, content.replaceAll('{{APP_NAME}}', appName))
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
await substitute(targetDir)
|
|
50
|
+
|
|
51
|
+
console.log(`
|
|
52
|
+
Created ${appName}/
|
|
53
|
+
|
|
54
|
+
Next steps:
|
|
55
|
+
cd ${appName}
|
|
56
|
+
npm install
|
|
57
|
+
pip install -r backend/requirements.txt
|
|
58
|
+
npm run dev # frontend on :3000
|
|
59
|
+
python backend/app.py # backend on :5000 (separate terminal)
|
|
60
|
+
|
|
61
|
+
Rules: widgets/theme/i18n come from @sensolus/snt-agent-kit — import, don't copy.
|
|
62
|
+
ESLint enforces no deep imports and no Snt* re-declarations.
|
|
63
|
+
`)
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sensolus/create-snt-agent-app",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scaffold a new Sensolus agent app: React frontend wired to @sensolus/snt-agent-kit + Flask API proxy backend.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-snt-agent-app": "./index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js",
|
|
11
|
+
"template"
|
|
12
|
+
],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"license": "UNLICENSED"
|
|
17
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# {{APP_NAME}}
|
|
2
|
+
|
|
3
|
+
Sensolus agent app, generated by `@sensolus/create-snt-agent-app`.
|
|
4
|
+
|
|
5
|
+
## Run
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
pip install -r backend/requirements.txt
|
|
10
|
+
npm run dev # frontend :3000 (proxies /api -> :5000)
|
|
11
|
+
python backend/app.py # backend :5000, separate terminal
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Rules
|
|
15
|
+
|
|
16
|
+
- UI widgets, theme, colors and i18n framework come from **`@sensolus/snt-agent-kit`** —
|
|
17
|
+
import them, never copy their source into this repo.
|
|
18
|
+
- `npm run lint` enforces: no deep imports into the kit, no re-declaring `Snt*` components.
|
|
19
|
+
- Need widget customization? Use slots/render props; if none fits, PR the
|
|
20
|
+
`snt-agent-kit` repo. There is no eject path.
|
|
21
|
+
- Kit updates: `npm update @sensolus/snt-agent-kit`.
|
|
22
|
+
|
|
23
|
+
## i18n
|
|
24
|
+
|
|
25
|
+
App keys live in `src/i18n/messages.js`, merged via `<LocaleProvider messages>`.
|
|
26
|
+
Common strings (`common.*`, `table.*`) ship with the kit.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
{{APP_NAME}} backend — Flask proxy to the Sensolus public API.
|
|
3
|
+
|
|
4
|
+
Avoids CORS by forwarding /api/* to https://<SENSOLUS_DOMAIN>/rest/api/v2/*.
|
|
5
|
+
|
|
6
|
+
Auth (same contract as sample_micro_app):
|
|
7
|
+
1. Session cookie `prod-sensolus-token` -> Authorization: Bearer <token>
|
|
8
|
+
2. SENSOLUS_API_KEY env var -> ?apiKey=<key> query parameter
|
|
9
|
+
Cookie wins if both are present.
|
|
10
|
+
"""
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
from flask import Flask, jsonify, request, send_from_directory
|
|
15
|
+
|
|
16
|
+
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
17
|
+
|
|
18
|
+
SENSOLUS_DOMAIN = os.environ.get("SENSOLUS_DOMAIN", "cloud.sensolus.com")
|
|
19
|
+
SENSOLUS_BASE_URL = f"https://{SENSOLUS_DOMAIN}/rest/api/v2"
|
|
20
|
+
SENSOLUS_COOKIE_NAME = os.environ.get("SENSOLUS_COOKIE_NAME", "prod-sensolus-token")
|
|
21
|
+
SENSOLUS_API_KEY = os.environ.get("SENSOLUS_API_KEY")
|
|
22
|
+
|
|
23
|
+
app = Flask(__name__, static_folder=os.path.join(PROJECT_ROOT, "dist"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _auth(params, headers):
|
|
27
|
+
"""Apply Sensolus auth to outgoing request parts. Cookie beats API key."""
|
|
28
|
+
token = request.cookies.get(SENSOLUS_COOKIE_NAME)
|
|
29
|
+
if token:
|
|
30
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
31
|
+
elif SENSOLUS_API_KEY:
|
|
32
|
+
params["apiKey"] = SENSOLUS_API_KEY
|
|
33
|
+
return params, headers
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.route("/api/loginInfo")
|
|
37
|
+
def login_info():
|
|
38
|
+
"""User preferences for the kit's LocaleProvider (language, timezone, units)."""
|
|
39
|
+
params, headers = _auth({}, {})
|
|
40
|
+
try:
|
|
41
|
+
resp = requests.get(f"{SENSOLUS_BASE_URL}/loginInfo", params=params, headers=headers, timeout=10)
|
|
42
|
+
if resp.ok:
|
|
43
|
+
return jsonify(resp.json())
|
|
44
|
+
except requests.RequestException:
|
|
45
|
+
pass
|
|
46
|
+
return jsonify({}) # kit falls back to defaults
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.route("/api/<path:subpath>")
|
|
50
|
+
def api_proxy(subpath):
|
|
51
|
+
"""Generic GET proxy to the Sensolus API."""
|
|
52
|
+
params, headers = _auth(dict(request.args), {})
|
|
53
|
+
try:
|
|
54
|
+
resp = requests.get(f"{SENSOLUS_BASE_URL}/{subpath}", params=params, headers=headers, timeout=30)
|
|
55
|
+
return (resp.content, resp.status_code, {"Content-Type": resp.headers.get("Content-Type", "application/json")})
|
|
56
|
+
except requests.RequestException as exc:
|
|
57
|
+
return jsonify({"error": str(exc)}), 502
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --- Serve built frontend (production) ---------------------------------------
|
|
61
|
+
@app.route("/", defaults={"path": ""})
|
|
62
|
+
@app.route("/<path:path>")
|
|
63
|
+
def serve_frontend(path):
|
|
64
|
+
if path and os.path.exists(os.path.join(app.static_folder, path)):
|
|
65
|
+
return send_from_directory(app.static_folder, path)
|
|
66
|
+
return send_from_directory(app.static_folder, "index.html")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
app.run(host="0.0.0.0", port=5000, debug=True)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import js from '@eslint/js'
|
|
2
|
+
import react from 'eslint-plugin-react'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Guardrails for the locked @sensolus/snt-agent-kit (do not remove):
|
|
6
|
+
* 1. no deep imports into the kit's dist/
|
|
7
|
+
* 2. no re-declaring Snt* components in app code (use kit slots/props, or PR the kit)
|
|
8
|
+
*/
|
|
9
|
+
export default [
|
|
10
|
+
js.configs.recommended,
|
|
11
|
+
{
|
|
12
|
+
files: ['src/**/*.{js,jsx}'],
|
|
13
|
+
languageOptions: {
|
|
14
|
+
ecmaVersion: 'latest',
|
|
15
|
+
sourceType: 'module',
|
|
16
|
+
parserOptions: { ecmaFeatures: { jsx: true } },
|
|
17
|
+
globals: { fetch: 'readonly', console: 'readonly', window: 'readonly', document: 'readonly' },
|
|
18
|
+
},
|
|
19
|
+
plugins: { react },
|
|
20
|
+
rules: {
|
|
21
|
+
'react/jsx-uses-react': 'error',
|
|
22
|
+
'react/jsx-uses-vars': 'error',
|
|
23
|
+
'no-restricted-imports': ['error', {
|
|
24
|
+
patterns: [{
|
|
25
|
+
group: ['@sensolus/snt-agent-kit/*', '!@sensolus/snt-agent-kit/theme.css'],
|
|
26
|
+
message: 'Deep imports into @sensolus/snt-agent-kit are forbidden. Import from the package root (theme.css is the only allowed subpath).',
|
|
27
|
+
}],
|
|
28
|
+
}],
|
|
29
|
+
'no-restricted-syntax': ['error',
|
|
30
|
+
{
|
|
31
|
+
selector: 'FunctionDeclaration[id.name=/^Snt[A-Z]/]',
|
|
32
|
+
message: 'Do not re-declare Snt* components in app code. Use kit slots/render props, or open a PR on the agent-kit.',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
selector: 'VariableDeclarator[id.name=/^Snt[A-Z]/]',
|
|
36
|
+
message: 'Do not re-declare Snt* components in app code. Use kit slots/render props, or open a PR on the agent-kit.',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
selector: 'ClassDeclaration[id.name=/^Snt[A-Z]/]',
|
|
40
|
+
message: 'Do not re-declare Snt* components in app code. Use kit slots/render props, or open a PR on the agent-kit.',
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>{{APP_NAME}}</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.jsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{APP_NAME}}",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview",
|
|
10
|
+
"lint": "eslint src"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@sensolus/snt-agent-kit": "^0.1.0",
|
|
14
|
+
"react": "^19.2.3",
|
|
15
|
+
"react-dom": "^19.2.3",
|
|
16
|
+
"react-router-dom": "^7.1.1"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@eslint/js": "^9.18.0",
|
|
20
|
+
"@vitejs/plugin-react": "^5.1.2",
|
|
21
|
+
"eslint": "^9.18.0",
|
|
22
|
+
"eslint-plugin-react": "^7.37.0",
|
|
23
|
+
"vite": "^7.3.1"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { SntPageHeader, SntTable, SntBadge, SntButton, SntToolbar, SntLoadingOverlay, useLocale } from '@sensolus/snt-agent-kit'
|
|
3
|
+
|
|
4
|
+
export default function App() {
|
|
5
|
+
const { t } = useLocale()
|
|
6
|
+
const [orgs, setOrgs] = useState(null)
|
|
7
|
+
const [loading, setLoading] = useState(false)
|
|
8
|
+
|
|
9
|
+
async function load() {
|
|
10
|
+
setLoading(true)
|
|
11
|
+
try {
|
|
12
|
+
const res = await fetch('/api/organisations')
|
|
13
|
+
setOrgs(res.ok ? await res.json() : [])
|
|
14
|
+
} finally {
|
|
15
|
+
setLoading(false)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
useEffect(() => { load() }, [])
|
|
20
|
+
|
|
21
|
+
if (loading || orgs === null) return <SntLoadingOverlay message={t('common.loading')} />
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="app-container">
|
|
25
|
+
<SntPageHeader title={t('app.title')} />
|
|
26
|
+
<SntToolbar>
|
|
27
|
+
<SntButton variant="secondary" onClick={load}>{t('common.reload')}</SntButton>
|
|
28
|
+
</SntToolbar>
|
|
29
|
+
<SntTable
|
|
30
|
+
data={orgs}
|
|
31
|
+
columns={[
|
|
32
|
+
{ key: 'name', header: t('app.org.name') },
|
|
33
|
+
{ key: 'status', header: t('app.org.status'), render: (row, val) => <SntBadge variant={val === 'ACTIVE' ? 'success' : 'secondary'} text={String(val ?? '')} /> },
|
|
34
|
+
]}
|
|
35
|
+
/>
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-owned translation keys, merged into the kit's common strings via
|
|
3
|
+
* <LocaleProvider messages={messages}>. Add languages/keys as needed;
|
|
4
|
+
* English is the fallback for missing keys.
|
|
5
|
+
*/
|
|
6
|
+
export const messages = {
|
|
7
|
+
en: {
|
|
8
|
+
'app.title': '{{APP_NAME}}',
|
|
9
|
+
'app.org.name': 'Name',
|
|
10
|
+
'app.org.status': 'Status',
|
|
11
|
+
},
|
|
12
|
+
nl: {
|
|
13
|
+
'app.title': '{{APP_NAME}}',
|
|
14
|
+
'app.org.name': 'Naam',
|
|
15
|
+
'app.org.status': 'Status',
|
|
16
|
+
},
|
|
17
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import ReactDOM from 'react-dom/client'
|
|
3
|
+
import { LocaleProvider } from '@sensolus/snt-agent-kit'
|
|
4
|
+
import '@sensolus/snt-agent-kit/theme.css'
|
|
5
|
+
import App from './App'
|
|
6
|
+
import { messages } from './i18n/messages'
|
|
7
|
+
import './styles/app.css'
|
|
8
|
+
|
|
9
|
+
ReactDOM.createRoot(document.getElementById('root')).render(
|
|
10
|
+
<React.StrictMode>
|
|
11
|
+
<LocaleProvider messages={messages}>
|
|
12
|
+
<App />
|
|
13
|
+
</LocaleProvider>
|
|
14
|
+
</React.StrictMode>
|
|
15
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineConfig } from 'vite'
|
|
2
|
+
import react from '@vitejs/plugin-react'
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
plugins: [react()],
|
|
6
|
+
server: {
|
|
7
|
+
port: 3000,
|
|
8
|
+
host: true,
|
|
9
|
+
proxy: {
|
|
10
|
+
'/api': {
|
|
11
|
+
target: 'http://localhost:5000',
|
|
12
|
+
changeOrigin: true,
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
build: {
|
|
17
|
+
outDir: 'dist',
|
|
18
|
+
},
|
|
19
|
+
})
|