caraer-cli 0.1.2__py3-none-any.whl
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.
- caraer_cli/__init__.py +4 -0
- caraer_cli/api/__init__.py +1 -0
- caraer_cli/api/apps.py +78 -0
- caraer_cli/api/auth.py +53 -0
- caraer_cli/api/client.py +179 -0
- caraer_cli/api/functions.py +72 -0
- caraer_cli/api/installation.py +93 -0
- caraer_cli/api/integration_runtime.py +77 -0
- caraer_cli/api/projects.py +213 -0
- caraer_cli/api/sandboxes.py +29 -0
- caraer_cli/api/webhooks.py +59 -0
- caraer_cli/app_sync.py +697 -0
- caraer_cli/apps_local.py +274 -0
- caraer_cli/commands/__init__.py +1 -0
- caraer_cli/commands/apps.py +2025 -0
- caraer_cli/commands/auth.py +173 -0
- caraer_cli/commands/company.py +68 -0
- caraer_cli/commands/installation.py +182 -0
- caraer_cli/commands/profile.py +239 -0
- caraer_cli/commands/publish.py +40 -0
- caraer_cli/commands/sandbox.py +92 -0
- caraer_cli/commands/skill.py +165 -0
- caraer_cli/commands/webhooks.py +68 -0
- caraer_cli/completion.py +77 -0
- caraer_cli/completion_callbacks.py +162 -0
- caraer_cli/context.py +41 -0
- caraer_cli/errors.py +84 -0
- caraer_cli/formatters/__init__.py +1 -0
- caraer_cli/formatters/output.py +381 -0
- caraer_cli/formatters/timestamps.py +98 -0
- caraer_cli/install_completion.py +38 -0
- caraer_cli/local_app.py +135 -0
- caraer_cli/main.py +104 -0
- caraer_cli/models/__init__.py +1 -0
- caraer_cli/models/api.py +52 -0
- caraer_cli/project/__init__.py +17 -0
- caraer_cli/project/app_bars_sync.py +154 -0
- caraer_cli/project/app_manifest_template.py +68 -0
- caraer_cli/project/inbound_sync.py +192 -0
- caraer_cli/project/json_schemas.py +66 -0
- caraer_cli/project/lifecycle_sync.py +119 -0
- caraer_cli/project/local_dev.py +825 -0
- caraer_cli/project/manifest_edit.py +51 -0
- caraer_cli/project/marketplace_assemble.py +165 -0
- caraer_cli/project/oauth_providers_sync.py +180 -0
- caraer_cli/project/paths.py +102 -0
- caraer_cli/project/pricing_sync.py +81 -0
- caraer_cli/project/push_plan.py +590 -0
- caraer_cli/project/release.py +128 -0
- caraer_cli/project/scaffold.py +537 -0
- caraer_cli/project/schedules_sync.py +170 -0
- caraer_cli/project/schema.py +160 -0
- caraer_cli/project/settings_sync.py +77 -0
- caraer_cli/project/state.py +51 -0
- caraer_cli/project/sync.py +308 -0
- caraer_cli/project/typegen.py +278 -0
- caraer_cli/project/validate_app.py +1215 -0
- caraer_cli/project/webhooks_sync.py +233 -0
- caraer_cli/resolve.py +55 -0
- caraer_cli/schemas/app.caraer.schema.json +175 -0
- caraer_cli/schemas/function.caraer.schema.json +29 -0
- caraer_cli/schemas/inbound.caraer.schema.json +49 -0
- caraer_cli/schemas/lifecycle.caraer.schema.json +50 -0
- caraer_cli/schemas/schedule.caraer.schema.json +39 -0
- caraer_cli/schemas/webhook.caraer.schema.json +50 -0
- caraer_cli/skills/.gitkeep +2 -0
- caraer_cli/skills/caraer-apps/SKILL.md +172 -0
- caraer_cli/skills/caraer-apps/reference.md +163 -0
- caraer_cli/state/__init__.py +1 -0
- caraer_cli/state/config.py +148 -0
- caraer_cli/state/session.py +98 -0
- caraer_cli/utils.py +40 -0
- caraer_cli/wizard/__init__.py +9 -0
- caraer_cli/wizard/catalog.py +170 -0
- caraer_cli/wizard/marketplace.py +350 -0
- caraer_cli/wizard/prompts.py +99 -0
- caraer_cli/wizard/public_app.py +519 -0
- caraer_cli/wizard/scope_macros.py +214 -0
- caraer_cli-0.1.2.dist-info/METADATA +266 -0
- caraer_cli-0.1.2.dist-info/RECORD +83 -0
- caraer_cli-0.1.2.dist-info/WHEEL +4 -0
- caraer_cli-0.1.2.dist-info/entry_points.txt +2 -0
- caraer_cli-0.1.2.dist-info/licenses/LICENSE +8 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: caraer-apps
|
|
3
|
+
description: >-
|
|
4
|
+
Scaffolds, edits, validates, and deploys Caraer Apps V2 projects with the
|
|
5
|
+
caraer CLI (app.caraer.yaml, serverless functions, inbound routes, schedules,
|
|
6
|
+
lifecycle hooks, settings, app bars). Use when creating or changing a Caraer
|
|
7
|
+
public app, marketplace app, serverless function, webhook, inbound route, or
|
|
8
|
+
when the user mentions caraer apps, app.caraer.yaml, or caraer-cli.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Caraer Apps (CLI)
|
|
12
|
+
|
|
13
|
+
Build Caraer apps with the **caraer** CLI and the V2 layout (`platformVersion:
|
|
14
|
+
2026.2`). Prefer CLI scaffolds over inventing folders by hand.
|
|
15
|
+
|
|
16
|
+
## Install this skill
|
|
17
|
+
|
|
18
|
+
From a machine with `caraer-cli` installed:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
caraer skill install # ~/.cursor/skills/caraer-apps
|
|
22
|
+
caraer skill install --project # ./.cursor/skills/caraer-apps (this repo)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or copy `skills/caraer-apps/` from the [caraer-cli](https://github.com/Caraer-HQ/caraer-cli)
|
|
26
|
+
repo into `~/.cursor/skills/caraer-apps/`.
|
|
27
|
+
|
|
28
|
+
## Preconditions
|
|
29
|
+
|
|
30
|
+
1. CLI available: `caraer --version` (Python 3.10+).
|
|
31
|
+
2. Auth + company: `caraer auth login` then `caraer company select <uuid>`.
|
|
32
|
+
3. Work inside an app folder (has `caraer.json`) or pass `--file`.
|
|
33
|
+
|
|
34
|
+
## Golden path
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
caraer apps init --name my_app --label "My App"
|
|
38
|
+
cd my_app
|
|
39
|
+
# edit src/app/app.caraer.yaml + functions
|
|
40
|
+
caraer apps validate
|
|
41
|
+
caraer apps typegen
|
|
42
|
+
caraer apps push --dry-run
|
|
43
|
+
caraer apps push --deploy
|
|
44
|
+
caraer apps install
|
|
45
|
+
caraer apps status
|
|
46
|
+
caraer apps logs --follow
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Local loop without deploy:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
caraer apps dev
|
|
53
|
+
caraer apps test --function <name> --sample-only
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Project layout (V2)
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
caraer.json
|
|
60
|
+
src/app/
|
|
61
|
+
app.caraer.yaml # identity, auth, settings, pricing, app bars, OAuth
|
|
62
|
+
functions/<name>/ # function.caraer.json + index.js|main.py (+ shared.js)
|
|
63
|
+
lifecycle/*.json # install|uninstall|rotate|update → function
|
|
64
|
+
inbound/*.json # public HTTP → function
|
|
65
|
+
schedules/*.json # cron → function
|
|
66
|
+
webhooks/*.json # platform events → function
|
|
67
|
+
src/types/ # from `caraer apps typegen`
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
JSON Schema for IDE hints:
|
|
71
|
+
|
|
72
|
+
`https://raw.githubusercontent.com/Caraer-HQ/caraer-cli/main/schemas/app.caraer.schema.json`
|
|
73
|
+
|
|
74
|
+
Point the YAML at it:
|
|
75
|
+
|
|
76
|
+
```yaml
|
|
77
|
+
# yaml-language-server: $schema=https://raw.githubusercontent.com/Caraer-HQ/caraer-cli/main/schemas/app.caraer.schema.json
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Reference example: `examples/webhook-inbox` in the caraer-cli repo.
|
|
81
|
+
|
|
82
|
+
## Agent workflow
|
|
83
|
+
|
|
84
|
+
Copy and track:
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
App progress:
|
|
88
|
+
- [ ] Clarify job (inbound / schedule / record bar / OAuth / records)
|
|
89
|
+
- [ ] `caraer apps init` or open existing app
|
|
90
|
+
- [ ] Edit manifest settings (user-facing only)
|
|
91
|
+
- [ ] Add/edit functions (shared helpers copied per folder)
|
|
92
|
+
- [ ] Wire inbound / schedule / webhook / app bar / lifecycle
|
|
93
|
+
- [ ] `caraer apps validate` → fix until 0 errors
|
|
94
|
+
- [ ] `caraer apps push --dry-run` then `--deploy` when user asks
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Clarify before coding
|
|
98
|
+
|
|
99
|
+
Ask only what blocks design:
|
|
100
|
+
|
|
101
|
+
- Trigger: inbound HTTP, schedule, record event, app bar, or install lifecycle?
|
|
102
|
+
- Runtime: `nodejs22` (default) or `python312`?
|
|
103
|
+
- Need installation state/secrets? → `authMethod: API_KEY` (gets `installationToken`).
|
|
104
|
+
- Need Caraer user OAuth app install? → `OAUTH2` + redirect URIs.
|
|
105
|
+
- External provider (Google, etc.)? → `externalOAuthProviders` + `${ENV}` secrets.
|
|
106
|
+
|
|
107
|
+
### Settings UX rules
|
|
108
|
+
|
|
109
|
+
Installation settings are for **admins installing the app**, not developers.
|
|
110
|
+
|
|
111
|
+
- Use clear labels + `helpText`. Prefer `SWITCH`, `OBJECT_SINGLE_SELECT`,
|
|
112
|
+
`SINGLE_SELECT` over free-text when possible.
|
|
113
|
+
- Do **not** add `caraer_api_base` or other platform URLs — runtime injects
|
|
114
|
+
`body.caraerApiBase`.
|
|
115
|
+
- Do **not** ask for object/property names as raw strings when a select type exists.
|
|
116
|
+
- Keep required settings to the minimum that makes the app work.
|
|
117
|
+
|
|
118
|
+
### Functions
|
|
119
|
+
|
|
120
|
+
- Node: `exports.handler = async (req, res) => { ... }`.
|
|
121
|
+
- Python: `def handler(request): ...` returning `{statusCode, body}`.
|
|
122
|
+
- Each function folder deploys alone — **copy** `shared.js` / helpers into every
|
|
123
|
+
folder that needs them (see webhook-inbox).
|
|
124
|
+
- Read settings via flattened `body.settingsSchema` (`name` → `value`).
|
|
125
|
+
- Use `body.installationToken` + `body.appUuid` for
|
|
126
|
+
`/v2/apps/{appUuid}/installation/state|secrets|jobs`.
|
|
127
|
+
- Prefer `body.caraerApiBase` when calling Caraer APIs.
|
|
128
|
+
|
|
129
|
+
### Scaffolding commands
|
|
130
|
+
|
|
131
|
+
| Need | Command |
|
|
132
|
+
|------|---------|
|
|
133
|
+
| New function | `caraer apps add-function` |
|
|
134
|
+
| Inbound route | `caraer apps add-inbound` |
|
|
135
|
+
| Schedule | `caraer apps add-schedule` |
|
|
136
|
+
| Webhook | `caraer apps add-webhook` |
|
|
137
|
+
| Setting | `caraer apps add-setting` |
|
|
138
|
+
| App bar | `caraer apps add-app-bar` |
|
|
139
|
+
| Lifecycle | `caraer apps add-lifecycle-hook` |
|
|
140
|
+
| Pricing | `caraer apps add-pricing-plan` |
|
|
141
|
+
|
|
142
|
+
### Validate loop
|
|
143
|
+
|
|
144
|
+
After every structural edit:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
caraer apps validate --file .
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Fix errors before push. Warnings (e.g. missing inbound `sharedSecret`) are OK
|
|
151
|
+
to leave for the installer when documented in README.
|
|
152
|
+
|
|
153
|
+
### Deploy safety
|
|
154
|
+
|
|
155
|
+
- Never push/deploy/install unless the user asks.
|
|
156
|
+
- Use `caraer apps push --dry-run` first for non-trivial changes.
|
|
157
|
+
- Do not commit secrets (`.env`, OAuth client secrets, inbound shared secrets).
|
|
158
|
+
- Expand `${ENV_VAR}` OAuth client fields from the **process environment** on push.
|
|
159
|
+
|
|
160
|
+
## Anti-patterns
|
|
161
|
+
|
|
162
|
+
- Inventing V1 per-function GCP layouts when V2 is default.
|
|
163
|
+
- Hand-writing remote UUIDs into YAML (let push fill them).
|
|
164
|
+
- One giant shared package import across functions (runtime is per-folder).
|
|
165
|
+
- Putting developer-only knobs in `settingsSchema`.
|
|
166
|
+
- Skipping `validate` before `push`.
|
|
167
|
+
|
|
168
|
+
## Dig deeper
|
|
169
|
+
|
|
170
|
+
- Payload shapes, lifecycle events, settings flattening: [reference.md](reference.md)
|
|
171
|
+
- CLI docs in the caraer-cli repo: `docs/app_lifecycle.md`, `docs/platform_versioning.md`
|
|
172
|
+
- Schemas: `schemas/*.caraer.schema.json`
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Caraer Apps — reference
|
|
2
|
+
|
|
3
|
+
Companion to [SKILL.md](SKILL.md). Read when implementing handlers or wiring
|
|
4
|
+
marketplace modules.
|
|
5
|
+
|
|
6
|
+
## Auth methods
|
|
7
|
+
|
|
8
|
+
| `authMethod` | Use when | Notes |
|
|
9
|
+
|--------------|----------|-------|
|
|
10
|
+
| `API_KEY` | App needs `installationToken` for state/secrets/jobs | Typical for integrations |
|
|
11
|
+
| `OAUTH2` | Install uses Caraer OAuth app flow | Set `oauthRedirectUris` |
|
|
12
|
+
|
|
13
|
+
External providers (`externalOAuthProviders`) are separate from `authMethod`.
|
|
14
|
+
Connected tokens arrive as installation secrets (e.g. `gmail_access_token`).
|
|
15
|
+
|
|
16
|
+
## Handler envelope (SERVERLESS)
|
|
17
|
+
|
|
18
|
+
Approximate JSON body (also wrapped as `req.body` for Node):
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"event": "Installed",
|
|
23
|
+
"appUuid": "...",
|
|
24
|
+
"companyUuid": "...",
|
|
25
|
+
"installationToken": "...",
|
|
26
|
+
"caraerApiBase": "https://api.caraer.com/api",
|
|
27
|
+
"functionName": "catch",
|
|
28
|
+
"settingsSchema": [
|
|
29
|
+
{ "name": "inbox_label", "type": "SINGLE_LINE", "value": "Main" }
|
|
30
|
+
],
|
|
31
|
+
"secrets": {},
|
|
32
|
+
"payload": {}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Lifecycle `event` values: `Installed`, `Updated`, `Uninstalled`, `Rotated`.
|
|
37
|
+
|
|
38
|
+
Connect / Disconnect for external OAuth are **not** lifecycle webhooks — read
|
|
39
|
+
secrets from the payload / secrets API after the user connects.
|
|
40
|
+
|
|
41
|
+
## Installation APIs (Bearer installationToken)
|
|
42
|
+
|
|
43
|
+
Base: `{caraerApiBase}` (no trailing slash).
|
|
44
|
+
|
|
45
|
+
| Method | Path | Purpose |
|
|
46
|
+
|--------|------|---------|
|
|
47
|
+
| GET/PUT | `/v2/apps/{appUuid}/installation/state` | JSON blob state |
|
|
48
|
+
| GET/PUT | `/v2/apps/{appUuid}/installation/secrets` | Secret map |
|
|
49
|
+
| POST | `/v2/apps/{appUuid}/installation/jobs` | `{ "functionName", "payload" }` |
|
|
50
|
+
|
|
51
|
+
## Setting field types
|
|
52
|
+
|
|
53
|
+
`SINGLE_LINE`, `MULTI_LINE`, `SINGLE_SELECT`, `MULTI_SELECT`,
|
|
54
|
+
`RECORD_SINGLE_SELECT`, `RECORD_MULTI_SELECT`, `OBJECT_SINGLE_SELECT`,
|
|
55
|
+
`OBJECT_MULTI_SELECT`, `PROPERTY_SINGLE_SELECT`, `PROPERTY_MULTI_SELECT`,
|
|
56
|
+
`SWITCH`, `MAPPING`, `SECRET`.
|
|
57
|
+
|
|
58
|
+
Flatten helper:
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
function flattenSettings(schema) {
|
|
62
|
+
const out = {};
|
|
63
|
+
for (const field of schema || []) {
|
|
64
|
+
if (field && field.name) {
|
|
65
|
+
out[field.name] = field.value ?? field.defaultValue ?? null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`OBJECT_*_SELECT` values may be a string name or an object with `name` /
|
|
73
|
+
`internalName` — normalize before API calls.
|
|
74
|
+
|
|
75
|
+
## Inbound routes
|
|
76
|
+
|
|
77
|
+
`src/app/inbound/<name>.json`:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"name": "catch",
|
|
82
|
+
"authMode": "SHARED_SECRET",
|
|
83
|
+
"enqueue": true,
|
|
84
|
+
"serverlessFunction": { "name": "catch" }
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Public URL shape (after deploy):
|
|
89
|
+
|
|
90
|
+
`POST /api/v2/public/apps/{appUuid}/inbound/{name}?companyUuid={companyUuid}`
|
|
91
|
+
|
|
92
|
+
Header: `X-Caraer-Inbound-Secret: <sharedSecret>`.
|
|
93
|
+
|
|
94
|
+
Set `sharedSecret` before production traffic; `validate` warns if missing.
|
|
95
|
+
|
|
96
|
+
## Schedules
|
|
97
|
+
|
|
98
|
+
Six-field cron (sec min hour dom mon dow), example every 12 hours:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"name": "heartbeat",
|
|
103
|
+
"schedule": "0 0 */12 * * *",
|
|
104
|
+
"enabled": true,
|
|
105
|
+
"serverlessFunction": { "name": "heartbeat" }
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## App bars
|
|
110
|
+
|
|
111
|
+
Action locations can use SERVERLESS without `iframeUrl`:
|
|
112
|
+
|
|
113
|
+
- `RECORD_PREVIEW`, `RECORD_OVERVIEW`, `RECORD_DETAIL`, `TOOL_BAR`, `TRAIT_BAR`
|
|
114
|
+
|
|
115
|
+
Wire `webhook.serverlessFunction.name` to a local function folder name.
|
|
116
|
+
|
|
117
|
+
## Node handler skeleton
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
exports.handler = async (req, res) => {
|
|
121
|
+
try {
|
|
122
|
+
const body =
|
|
123
|
+
typeof req.body === "string"
|
|
124
|
+
? JSON.parse(req.body || "{}")
|
|
125
|
+
: req.body || {};
|
|
126
|
+
// ...
|
|
127
|
+
return res.status(200).json({ ok: true });
|
|
128
|
+
} catch (err) {
|
|
129
|
+
return res.status(500).json({
|
|
130
|
+
ok: false,
|
|
131
|
+
error: String(err && err.message ? err.message : err),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Python handler skeleton
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
def handler(request):
|
|
141
|
+
body = request.get("body") if isinstance(request, dict) else {}
|
|
142
|
+
if isinstance(body, str):
|
|
143
|
+
import json
|
|
144
|
+
body = json.loads(body or "{}")
|
|
145
|
+
return {"statusCode": 200, "body": {"ok": True}}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Useful CLI
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
caraer apps current
|
|
152
|
+
caraer apps validate
|
|
153
|
+
caraer apps push --dry-run
|
|
154
|
+
caraer apps push --deploy
|
|
155
|
+
caraer apps status
|
|
156
|
+
caraer apps logs --follow
|
|
157
|
+
caraer apps state get
|
|
158
|
+
caraer apps jobs list
|
|
159
|
+
caraer apps connections list
|
|
160
|
+
caraer apps test --function <name> --record <uuid>
|
|
161
|
+
caraer apps dev --invoke-schedule <schedule-name>
|
|
162
|
+
caraer skill install --project
|
|
163
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Configuration and session helpers."""
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from platformdirs import user_config_dir
|
|
10
|
+
|
|
11
|
+
if sys.version_info >= (3, 11):
|
|
12
|
+
import tomllib
|
|
13
|
+
else:
|
|
14
|
+
import tomli as tomllib
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
CONFIG_DIRNAME = "caraer-cli"
|
|
18
|
+
CONFIG_FILENAME = "config.toml"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ProfileConfig:
|
|
23
|
+
base_url: str
|
|
24
|
+
company_uuid: str | None = None
|
|
25
|
+
sandbox_uuid: str | None = None
|
|
26
|
+
app_uuid: str | None = None
|
|
27
|
+
app_file: str | None = None
|
|
28
|
+
output: str = "table"
|
|
29
|
+
timeout_seconds: float = 120.0
|
|
30
|
+
verify_ssl: bool = True
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class CliConfig:
|
|
35
|
+
active_profile: str = "dev"
|
|
36
|
+
profiles: dict[str, ProfileConfig] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def default() -> "CliConfig":
|
|
40
|
+
return CliConfig(
|
|
41
|
+
active_profile="dev",
|
|
42
|
+
profiles={
|
|
43
|
+
"dev": ProfileConfig(base_url="http://localhost:8080"),
|
|
44
|
+
"staging": ProfileConfig(base_url="https://staging.caraer.com"),
|
|
45
|
+
"prod": ProfileConfig(base_url="https://caraer.com"),
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def config_dir() -> Path:
|
|
51
|
+
return Path(user_config_dir(CONFIG_DIRNAME))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def config_path() -> Path:
|
|
55
|
+
return config_dir() / CONFIG_FILENAME
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _toml_value(value: Any) -> str:
|
|
59
|
+
if isinstance(value, str):
|
|
60
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
61
|
+
return f'"{escaped}"'
|
|
62
|
+
if isinstance(value, bool):
|
|
63
|
+
return "true" if value else "false"
|
|
64
|
+
if value is None:
|
|
65
|
+
return '""'
|
|
66
|
+
return str(value)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _to_toml(cfg: CliConfig) -> str:
|
|
70
|
+
lines: list[str] = [f'active_profile = "{cfg.active_profile}"', ""]
|
|
71
|
+
for profile_name, profile in cfg.profiles.items():
|
|
72
|
+
lines.extend(
|
|
73
|
+
[
|
|
74
|
+
f"[profiles.{profile_name}]",
|
|
75
|
+
f"base_url = {_toml_value(profile.base_url)}",
|
|
76
|
+
f"company_uuid = {_toml_value(profile.company_uuid)}",
|
|
77
|
+
f"sandbox_uuid = {_toml_value(profile.sandbox_uuid)}",
|
|
78
|
+
f"app_uuid = {_toml_value(profile.app_uuid)}",
|
|
79
|
+
f"app_file = {_toml_value(profile.app_file)}",
|
|
80
|
+
f"output = {_toml_value(profile.output)}",
|
|
81
|
+
f"timeout_seconds = {_toml_value(profile.timeout_seconds)}",
|
|
82
|
+
f"verify_ssl = {_toml_value(profile.verify_ssl)}",
|
|
83
|
+
"",
|
|
84
|
+
]
|
|
85
|
+
)
|
|
86
|
+
return "\n".join(lines).strip() + "\n"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_config() -> CliConfig:
|
|
90
|
+
path = config_path()
|
|
91
|
+
if not path.exists():
|
|
92
|
+
cfg = CliConfig.default()
|
|
93
|
+
save_config(cfg)
|
|
94
|
+
return cfg
|
|
95
|
+
|
|
96
|
+
with path.open("rb") as handle:
|
|
97
|
+
raw = tomllib.load(handle)
|
|
98
|
+
|
|
99
|
+
cfg = CliConfig.default()
|
|
100
|
+
cfg.active_profile = raw.get("active_profile", cfg.active_profile)
|
|
101
|
+
profile_block = raw.get("profiles", {})
|
|
102
|
+
if isinstance(profile_block, dict):
|
|
103
|
+
cfg.profiles = {}
|
|
104
|
+
for name, value in profile_block.items():
|
|
105
|
+
if not isinstance(value, dict):
|
|
106
|
+
continue
|
|
107
|
+
cfg.profiles[name] = ProfileConfig(
|
|
108
|
+
base_url=str(value.get("base_url", "http://localhost:8080")),
|
|
109
|
+
company_uuid=value.get("company_uuid") or None,
|
|
110
|
+
sandbox_uuid=value.get("sandbox_uuid") or None,
|
|
111
|
+
app_uuid=value.get("app_uuid") or None,
|
|
112
|
+
app_file=value.get("app_file") or None,
|
|
113
|
+
output=str(value.get("output", "table")),
|
|
114
|
+
timeout_seconds=float(value.get("timeout_seconds", 120.0)),
|
|
115
|
+
verify_ssl=bool(value.get("verify_ssl", True)),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if cfg.active_profile not in cfg.profiles:
|
|
119
|
+
cfg.active_profile = next(iter(cfg.profiles.keys()), "dev")
|
|
120
|
+
return cfg
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def save_config(cfg: CliConfig) -> None:
|
|
124
|
+
dir_path = config_dir()
|
|
125
|
+
dir_path.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
path = config_path()
|
|
127
|
+
path.write_text(_to_toml(cfg), encoding="utf-8")
|
|
128
|
+
os.chmod(path, 0o600)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def active_profile(cfg: CliConfig, override: str | None = None) -> tuple[str, ProfileConfig]:
|
|
132
|
+
key = override or cfg.active_profile
|
|
133
|
+
if key not in cfg.profiles:
|
|
134
|
+
raise ValueError(f"Unknown profile '{key}'.")
|
|
135
|
+
return key, cfg.profiles[key]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def profile_as_dict(profile: ProfileConfig) -> dict[str, Any]:
|
|
139
|
+
return {
|
|
140
|
+
"base_url": profile.base_url,
|
|
141
|
+
"company_uuid": profile.company_uuid,
|
|
142
|
+
"sandbox_uuid": profile.sandbox_uuid,
|
|
143
|
+
"app_uuid": profile.app_uuid,
|
|
144
|
+
"app_file": profile.app_file,
|
|
145
|
+
"output": profile.output,
|
|
146
|
+
"timeout_seconds": profile.timeout_seconds,
|
|
147
|
+
"verify_ssl": profile.verify_ssl,
|
|
148
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import keyring
|
|
9
|
+
from platformdirs import user_config_dir
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
APP_NAME = "caraer-cli"
|
|
13
|
+
SERVICE_NAME = "caraer-cli-session"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _fallback_session_path() -> Path:
|
|
17
|
+
path = Path(user_config_dir(APP_NAME)) / "sessions.json"
|
|
18
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
if not path.exists():
|
|
20
|
+
path.write_text("{}", encoding="utf-8")
|
|
21
|
+
os.chmod(path, 0o600)
|
|
22
|
+
return path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load_fallback() -> dict[str, Any]:
|
|
26
|
+
path = _fallback_session_path()
|
|
27
|
+
try:
|
|
28
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
29
|
+
except json.JSONDecodeError:
|
|
30
|
+
return {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _save_fallback(data: dict[str, Any]) -> None:
|
|
34
|
+
path = _fallback_session_path()
|
|
35
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
36
|
+
os.chmod(path, 0o600)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_session_token(profile: str) -> str | None:
|
|
40
|
+
try:
|
|
41
|
+
token = keyring.get_password(SERVICE_NAME, profile)
|
|
42
|
+
if token:
|
|
43
|
+
return token
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
data = _load_fallback()
|
|
48
|
+
value = data.get(profile, {})
|
|
49
|
+
token = value.get("token")
|
|
50
|
+
return str(token) if token else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_refresh_token(profile: str) -> str | None:
|
|
54
|
+
data = _load_fallback()
|
|
55
|
+
value = data.get(profile, {})
|
|
56
|
+
if not isinstance(value, dict):
|
|
57
|
+
return None
|
|
58
|
+
token = value.get("refreshToken")
|
|
59
|
+
return str(token) if token else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def set_session_token(
|
|
63
|
+
profile: str,
|
|
64
|
+
token: str,
|
|
65
|
+
*,
|
|
66
|
+
refresh_token: str | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
wrote_keyring = False
|
|
69
|
+
try:
|
|
70
|
+
keyring.set_password(SERVICE_NAME, profile, token)
|
|
71
|
+
wrote_keyring = True
|
|
72
|
+
except Exception:
|
|
73
|
+
wrote_keyring = False
|
|
74
|
+
|
|
75
|
+
# Always persist tokens in the fallback file so refreshToken survives
|
|
76
|
+
# even when the access token is stored in the OS keyring.
|
|
77
|
+
data = _load_fallback()
|
|
78
|
+
data.setdefault(profile, {})
|
|
79
|
+
if not isinstance(data[profile], dict):
|
|
80
|
+
data[profile] = {}
|
|
81
|
+
data[profile]["token"] = token
|
|
82
|
+
if refresh_token is not None:
|
|
83
|
+
data[profile]["refreshToken"] = refresh_token
|
|
84
|
+
if not wrote_keyring or refresh_token is not None:
|
|
85
|
+
_save_fallback(data)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def clear_session_token(profile: str) -> None:
|
|
89
|
+
try:
|
|
90
|
+
keyring.delete_password(SERVICE_NAME, profile)
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
data = _load_fallback()
|
|
95
|
+
if profile in data and isinstance(data[profile], dict):
|
|
96
|
+
data[profile].pop("token", None)
|
|
97
|
+
data[profile].pop("refreshToken", None)
|
|
98
|
+
_save_fallback(data)
|
caraer_cli/utils.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_structured_file(path: str) -> dict[str, Any]:
|
|
11
|
+
content = Path(path).read_text(encoding="utf-8")
|
|
12
|
+
suffix = Path(path).suffix.lower()
|
|
13
|
+
if suffix in (".yaml", ".yml"):
|
|
14
|
+
payload = yaml.safe_load(content)
|
|
15
|
+
else:
|
|
16
|
+
payload = json.loads(content)
|
|
17
|
+
if not isinstance(payload, dict):
|
|
18
|
+
raise ValueError("Input file must contain a JSON/YAML object at the root.")
|
|
19
|
+
return payload
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_patch(value: str) -> dict[str, Any]:
|
|
23
|
+
parsed = json.loads(value)
|
|
24
|
+
if not isinstance(parsed, dict):
|
|
25
|
+
raise ValueError("Patch must be a JSON object.")
|
|
26
|
+
return parsed
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def deep_merge(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
|
|
30
|
+
merged = dict(base)
|
|
31
|
+
for key, value in patch.items():
|
|
32
|
+
if (
|
|
33
|
+
key in merged
|
|
34
|
+
and isinstance(merged[key], dict)
|
|
35
|
+
and isinstance(value, dict)
|
|
36
|
+
):
|
|
37
|
+
merged[key] = deep_merge(merged[key], value)
|
|
38
|
+
else:
|
|
39
|
+
merged[key] = value
|
|
40
|
+
return merged
|