@ekanos/cli 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/LICENSE +21 -0
- package/README.md +326 -0
- package/dist/auth/credential-store.d.ts +96 -0
- package/dist/auth/credential-store.js +274 -0
- package/dist/auth/credential-store.js.map +1 -0
- package/dist/auth/device-flow.d.ts +57 -0
- package/dist/auth/device-flow.js +201 -0
- package/dist/auth/device-flow.js.map +1 -0
- package/dist/auth/fusion-api.d.ts +48 -0
- package/dist/auth/fusion-api.js +310 -0
- package/dist/auth/fusion-api.js.map +1 -0
- package/dist/auth/session.d.ts +57 -0
- package/dist/auth/session.js +114 -0
- package/dist/auth/session.js.map +1 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +20 -0
- package/dist/bin.js.map +1 -0
- package/dist/commands/dev.d.ts +36 -0
- package/dist/commands/dev.js +207 -0
- package/dist/commands/dev.js.map +1 -0
- package/dist/commands/init.d.ts +30 -0
- package/dist/commands/init.js +178 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/login.d.ts +20 -0
- package/dist/commands/login.js +92 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/logout.d.ts +20 -0
- package/dist/commands/logout.js +75 -0
- package/dist/commands/logout.js.map +1 -0
- package/dist/commands/test.d.ts +16 -0
- package/dist/commands/test.js +54 -0
- package/dist/commands/test.js.map +1 -0
- package/dist/commands/validate.d.ts +14 -0
- package/dist/commands/validate.js +98 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/commands/whoami.d.ts +18 -0
- package/dist/commands/whoami.js +29 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/compatibility.d.ts +81 -0
- package/dist/compatibility.js +135 -0
- package/dist/compatibility.js.map +1 -0
- package/dist/context.d.ts +81 -0
- package/dist/context.js +125 -0
- package/dist/context.js.map +1 -0
- package/dist/errors.d.ts +41 -0
- package/dist/errors.js +111 -0
- package/dist/errors.js.map +1 -0
- package/dist/exit-codes.d.ts +52 -0
- package/dist/exit-codes.js +51 -0
- package/dist/exit-codes.js.map +1 -0
- package/dist/harness-scaffold.d.ts +212 -0
- package/dist/harness-scaffold.js +434 -0
- package/dist/harness-scaffold.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +262 -0
- package/dist/index.js.map +1 -0
- package/dist/load-definition.d.ts +20 -0
- package/dist/load-definition.js +126 -0
- package/dist/load-definition.js.map +1 -0
- package/dist/merge-manifest.d.ts +44 -0
- package/dist/merge-manifest.js +124 -0
- package/dist/merge-manifest.js.map +1 -0
- package/dist/package-manager.d.ts +25 -0
- package/dist/package-manager.js +57 -0
- package/dist/package-manager.js.map +1 -0
- package/dist/parse-argv.d.ts +27 -0
- package/dist/parse-argv.js +74 -0
- package/dist/parse-argv.js.map +1 -0
- package/dist/project-checks.d.ts +18 -0
- package/dist/project-checks.js +77 -0
- package/dist/project-checks.js.map +1 -0
- package/dist/project.d.ts +140 -0
- package/dist/project.js +228 -0
- package/dist/project.js.map +1 -0
- package/dist/safe-fs.d.ts +55 -0
- package/dist/safe-fs.js +209 -0
- package/dist/safe-fs.js.map +1 -0
- package/dist/templates.d.ts +23 -0
- package/dist/templates.js +49 -0
- package/dist/templates.js.map +1 -0
- package/package.json +62 -0
- package/templates/ekanos.json.tmpl +4 -0
- package/templates/gitignore.tmpl +12 -0
- package/templates/harness/app/[slug]/activation/page.tsx.tmpl +1 -0
- package/templates/harness/app/[slug]/layout.tsx.tmpl +1 -0
- package/templates/harness/app/[slug]/tile/page.tsx.tmpl +1 -0
- package/templates/harness/app/[slug]/triggers/page.tsx.tmpl +1 -0
- package/templates/harness/app/[slug]/widgets/[widgetId]/page.tsx.tmpl +1 -0
- package/templates/harness/app/[slug]/widgets/page.tsx.tmpl +1 -0
- package/templates/harness/app/harness-shell.tsx.tmpl +9 -0
- package/templates/harness/app/layout.tsx.tmpl +4 -0
- package/templates/harness/app/page.tsx.tmpl +7 -0
- package/templates/harness/next.config.mjs.tmpl +46 -0
- package/templates/harness/package.json.tmpl +11 -0
- package/templates/harness/postcss.config.mjs.tmpl +6 -0
- package/templates/harness/styles/globals.css.tmpl +33 -0
- package/templates/harness/tsconfig.json.tmpl +31 -0
- package/templates/harness/types/team-accounts.d.ts.tmpl +16 -0
- package/templates/harness.config.entry.ts.tmpl +65 -0
- package/templates/harness.config.ts.tmpl +58 -0
- package/templates/package.json.tmpl +28 -0
- package/templates/src/integration.test.ts.tmpl +24 -0
- package/templates/src/integration.ts.tmpl +83 -0
- package/templates/tsconfig.json.tmpl +19 -0
- package/templates/vitest.config.ts.tmpl +32 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vastly
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# @ekanos/cli
|
|
2
|
+
|
|
3
|
+
The Ekanos partner toolchain. Scaffold, validate, and test a Fusion integration
|
|
4
|
+
against the published `@ekanos` packages — from any Node project, with no
|
|
5
|
+
bundler and no dependency on `@ekanos/sdk` at runtime.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @ekanos/cli init --slug my-integration --yes # works in an empty dir, or merges into an existing package.json
|
|
9
|
+
npx @ekanos/cli validate
|
|
10
|
+
npx @ekanos/cli test
|
|
11
|
+
npx @ekanos/cli dev
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Verbs
|
|
15
|
+
|
|
16
|
+
| Verb | What it does |
|
|
17
|
+
|---|---|
|
|
18
|
+
| `init` | Scaffold `ekanos.json`, a working `defineIntegration`, a test, a `tsconfig.json`, a vitest config, `.gitignore`, and a `package.json` with real published deps. Runs in an empty directory or on top of an existing project. |
|
|
19
|
+
| `validate` | Load the definition via esbuild and validate it with the **real** `@ekanos/integration-schema` zod schemas; add project checks (the required vitest SDK-inline block). |
|
|
20
|
+
| `dev` | Scaffold `.ekanos/harness/` and run it with **your** Next, so your widgets, tile, activation form and triggers render in real Fusion chrome. |
|
|
21
|
+
| `test` | Delegate to the project's own test script via its package manager. |
|
|
22
|
+
| `login` | Authenticate this machine against a Fusion deployment (Auth0 device flow). |
|
|
23
|
+
| `logout` | Revoke the session at Auth0 and delete the local credentials. |
|
|
24
|
+
| `whoami` | Print the identity the stored session authenticates as. |
|
|
25
|
+
|
|
26
|
+
## Global flags
|
|
27
|
+
|
|
28
|
+
| Flag | What it does |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `--json` | Force the JSON envelope on, even at a terminal. |
|
|
31
|
+
| `--no-json` | Force the human-readable form on, even when piped. |
|
|
32
|
+
| `--version` | Print the CLI version and exit 0. Works with or without a verb. |
|
|
33
|
+
| `--help` | Print usage. Also available per-verb (`ekanos validate --help`). |
|
|
34
|
+
|
|
35
|
+
Short flags are not supported — the parser rejects them rather than guessing.
|
|
36
|
+
|
|
37
|
+
## Agent-native envelope
|
|
38
|
+
|
|
39
|
+
Every verb speaks JSON. JSON mode is **inferred** from a non-TTY stdout or
|
|
40
|
+
`CLAUDECODE=1`, and an explicit `--json` / `--no-json` overrides that inference
|
|
41
|
+
in either direction. In JSON mode **stdout carries exactly one JSON object** —
|
|
42
|
+
`{ ok, data, error: { code, message, hint } }` — and every progress line goes to
|
|
43
|
+
stderr. Errors always carry an imperative `hint`.
|
|
44
|
+
|
|
45
|
+
The CLI never prompts, in any mode. `init` requires `--slug` and fails with a
|
|
46
|
+
usage error naming the flag rather than asking for it.
|
|
47
|
+
|
|
48
|
+
### Exit codes (frozen public contract)
|
|
49
|
+
|
|
50
|
+
`0` ok · `1` internal · `2` usage · `3` validation-findings · `4` auth-required ·
|
|
51
|
+
`5` forbidden · `6` not-found · `7` invalid-state · `8` network ·
|
|
52
|
+
`9` precondition-failed · `10` gate-failed.
|
|
53
|
+
|
|
54
|
+
## `ekanos.json`
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{ "slug": "my-integration", "entry": "src/integration.ts" }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
More than one integration in a project? Use the list form instead — `validate`
|
|
61
|
+
then cross-checks them against each other (colliding slugs, widget ids, tool
|
|
62
|
+
names), which it cannot do when it can only see one:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{ "integrations": [
|
|
66
|
+
{ "slug": "acme-crm", "entry": "src/crm.ts" },
|
|
67
|
+
{ "slug": "acme-billing", "entry": "src/billing.ts" }
|
|
68
|
+
] }
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Declare one form or the other, never both. `validate` also checks that each
|
|
72
|
+
`slug` here agrees with the `slug` in the definition it points at — they
|
|
73
|
+
address the same integration everywhere, so a disagreement is an error rather
|
|
74
|
+
than a warning.
|
|
75
|
+
|
|
76
|
+
### Every field
|
|
77
|
+
|
|
78
|
+
| Field | Required | Where | What |
|
|
79
|
+
|---|---|---|---|
|
|
80
|
+
| `slug` | yes, in the single form | top level | kebab-case; must equal the definition's `slug` |
|
|
81
|
+
| `entry` | yes, in the single form | top level | path to the module exporting your `defineIntegration(...)` result |
|
|
82
|
+
| `integrations[]` | yes, in the list form | top level | `{ slug, entry }` per integration, same rules, slugs unique |
|
|
83
|
+
| `sourceGlobs` | no | **top level only** | extra Tailwind `@source` globs; project-relative, no `..` |
|
|
84
|
+
| `$schema` | no | top level | ignored, for editor tooling |
|
|
85
|
+
|
|
86
|
+
The file is **strict**: an unrecognised key is an error naming the key, not a
|
|
87
|
+
silent no-op. `sourceGlobs` is deliberately not per-integration — Tailwind scans
|
|
88
|
+
one content set for the whole shell, so splitting it per integration would
|
|
89
|
+
imply an isolation that does not exist.
|
|
90
|
+
|
|
91
|
+
### The generated shell
|
|
92
|
+
|
|
93
|
+
`ekanos dev` writes exactly this, and rewrites any of it whose bytes drift:
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
.ekanos/harness/
|
|
97
|
+
.ekanos-version { harness, cli, templates } — what generated this
|
|
98
|
+
app/layout.tsx stylesheet + metadata + shell re-export
|
|
99
|
+
app/harness-shell.tsx 'use client' — injects your registry
|
|
100
|
+
app/page.tsx server component — the index
|
|
101
|
+
app/[slug]/layout.tsx ─┐
|
|
102
|
+
app/[slug]/widgets/page.tsx │
|
|
103
|
+
app/[slug]/widgets/[widgetId]/page.tsx
|
|
104
|
+
app/[slug]/tile/page.tsx ├─ one-line re-exports of @ekanos/harness/routes
|
|
105
|
+
app/[slug]/activation/page.tsx
|
|
106
|
+
app/[slug]/triggers/page.tsx ─┘
|
|
107
|
+
next.config.mjs transpiles @ekanos/harness, aliases the workspace mock
|
|
108
|
+
postcss.config.mjs @tailwindcss/postcss
|
|
109
|
+
tsconfig.json no baseUrl, no paths
|
|
110
|
+
styles/globals.css @import '@ekanos/harness/styles.css' + your @source globs
|
|
111
|
+
types/team-accounts.d.ts types the workspace mock through the export map
|
|
112
|
+
package.json scripts only — no deps, everything resolves upward
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`next dev`/`next build` add `next-env.d.ts`, `.next/`, and (Next 16)
|
|
116
|
+
`AGENTS.md`/`CLAUDE.md` alongside them. None of that is ours and none of it is
|
|
117
|
+
tracked — `.ekanos/` is gitignored in full.
|
|
118
|
+
|
|
119
|
+
`entry` points at the module exporting your `defineIntegration(...)` result
|
|
120
|
+
(default export, or any named export that is an object with a string `slug`).
|
|
121
|
+
|
|
122
|
+
## `ekanos dev`
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
pnpm add -D @ekanos/harness next react react-dom tailwindcss @tailwindcss/postcss
|
|
126
|
+
npx @ekanos/cli dev # scaffold, then run on http://localhost:3100
|
|
127
|
+
npx @ekanos/cli dev --port 4000
|
|
128
|
+
npx @ekanos/cli dev --no-start # scaffold only
|
|
129
|
+
npx @ekanos/cli dev --host 0.0.0.0 # expose on your network — read below first
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### The harness binds loopback
|
|
133
|
+
|
|
134
|
+
`dev` passes `--hostname 127.0.0.1` to Next, which by default would bind
|
|
135
|
+
`0.0.0.0`. The harness has no authentication and renders your fixtures — and,
|
|
136
|
+
in live mode, real responses from your vendor API — so on a shared network a
|
|
137
|
+
default wildcard bind hands all of that to anyone who can reach the port.
|
|
138
|
+
|
|
139
|
+
`--host` opts in explicitly (for a phone on the same Wi-Fi, say). It prints a
|
|
140
|
+
warning saying what is now reachable, and the JSON envelope carries
|
|
141
|
+
`server.exposed: true` so a script can refuse to run that way.
|
|
142
|
+
|
|
143
|
+
### Who owns what
|
|
144
|
+
|
|
145
|
+
| Path | Owner | You edit it? |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| `harness.config.ts` (project root) | **you** | **yes — the only one.** Written once, git-tracked, never touched again. |
|
|
148
|
+
| your integration source | **you** | yes |
|
|
149
|
+
| `.ekanos/harness/**` | generated | no — gitignored, rewritten on every run |
|
|
150
|
+
| `@ekanos/harness` | us | no — it is an ordinary dependency |
|
|
151
|
+
|
|
152
|
+
That split is the point: a harness bugfix ships as an `@ekanos/harness` version
|
|
153
|
+
bump you pick up with `pnpm up`, with zero edits to any file you own. Every
|
|
154
|
+
file in the generated shell is mechanical — re-exports of
|
|
155
|
+
`@ekanos/harness/routes`, an app shell that injects your registry, and
|
|
156
|
+
configs — so none of it ever needs a judgment call from you;
|
|
157
|
+
[The generated shell](#the-generated-shell) above is the exact list.
|
|
158
|
+
`.ekanos/harness/.ekanos-version` records which `@ekanos/harness` generated it,
|
|
159
|
+
and when that disagrees with the installed version the shell is regenerated
|
|
160
|
+
automatically and `dev` says so on stderr.
|
|
161
|
+
|
|
162
|
+
Re-running is free: files whose bytes already match are not rewritten (so Next's
|
|
163
|
+
watcher stays quiet), and `.ekanos/` is added to `.gitignore` exactly once.
|
|
164
|
+
`--force` rewrites the generated shell; nothing rewrites `harness.config.ts`.
|
|
165
|
+
|
|
166
|
+
`dev` refuses to write through a symlink, to write outside your project, or to
|
|
167
|
+
overwrite a `.ekanos/harness` it did not generate (one with no `.ekanos-version`
|
|
168
|
+
marker) unless you pass `--force`. Each exits 7 having changed nothing.
|
|
169
|
+
|
|
170
|
+
### One constraint on your own code
|
|
171
|
+
|
|
172
|
+
`harness.config.ts` is imported from both a server component and a client
|
|
173
|
+
component, so everything reachable from it must be importable in both graphs.
|
|
174
|
+
Deriving fixture query keys from a `'use client'` module compiles and then
|
|
175
|
+
fails at request time with `Attempted to call … from the server`. Move the key
|
|
176
|
+
builders into a plain module with no `'use client'` directive and have your
|
|
177
|
+
hooks import that — which is also how you keep fixtures and hooks from
|
|
178
|
+
drifting apart.
|
|
179
|
+
|
|
180
|
+
### Where your source lives
|
|
181
|
+
|
|
182
|
+
The generated `styles/globals.css` points Tailwind at the usual places
|
|
183
|
+
(`src/`, `app/`, `lib/`, `components/`, `integrations/`, `packages/`). If your
|
|
184
|
+
integration lives somewhere else, add globs to `ekanos.json` — do NOT edit the
|
|
185
|
+
stylesheet, which is regenerated on every run:
|
|
186
|
+
|
|
187
|
+
```json
|
|
188
|
+
{ "slug": "acme-crm", "entry": "src/integration.ts",
|
|
189
|
+
"sourceGlobs": ["vendor/acme/**/*.{ts,tsx}"] }
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
A glob Tailwind is not pointed at costs you a silently unstyled integration
|
|
193
|
+
with no build error, so these are validated strictly: project-relative, no
|
|
194
|
+
`..`, and a restricted character set, because they are written verbatim into
|
|
195
|
+
generated CSS.
|
|
196
|
+
|
|
197
|
+
### Version compatibility
|
|
198
|
+
|
|
199
|
+
The shell templates ship with `@ekanos/cli`; the code they import ships with
|
|
200
|
+
`@ekanos/harness`. Both declare a **shell contract** — an integer that changes
|
|
201
|
+
only when the shell a harness requires changes (an export moving, the
|
|
202
|
+
server/client split, the set of files). `dev` compares them before it writes
|
|
203
|
+
anything and names the mismatch — `@ekanos/cli 0.1.0 is too old for
|
|
204
|
+
@ekanos/harness 0.2.0: that harness needs shell contract 2, and this CLI
|
|
205
|
+
generates 1` — rather than letting it surface later as a module-resolution
|
|
206
|
+
error. A version bump that does not change the contract never blocks an
|
|
207
|
+
upgrade.
|
|
208
|
+
|
|
209
|
+
`.ekanos/harness/.ekanos-version` records the harness version, the CLI version
|
|
210
|
+
and a hash of the templates, so a change to any of them regenerates the shell
|
|
211
|
+
and `dev` says which one moved.
|
|
212
|
+
|
|
213
|
+
**For maintainers:** propagation is byte-driven — `dev` rewrites a generated
|
|
214
|
+
file whenever its content differs, marker or not. So a change to the SHELL
|
|
215
|
+
ships in an `@ekanos/cli` release, never a harness one: bumping
|
|
216
|
+
`@ekanos/harness` alone cannot deliver a new template, because the templates
|
|
217
|
+
are not in that package. Bump `SHELL_CONTRACT` in
|
|
218
|
+
`src/compatibility.ts` only when the coupling changes, not for every template
|
|
219
|
+
edit.
|
|
220
|
+
|
|
221
|
+
### For agents
|
|
222
|
+
|
|
223
|
+
`dev` in JSON mode (`--json`, a pipe, or `CLAUDECODE=1`) **scaffolds and
|
|
224
|
+
returns** rather than blocking on a dev server that never exits — the envelope's
|
|
225
|
+
`data.server` carries `{ started: false, command, url, port }` so the caller can
|
|
226
|
+
start it itself. Pass `--start` to override.
|
|
227
|
+
|
|
228
|
+
## `ekanos init` on an existing project
|
|
229
|
+
|
|
230
|
+
`npm init -y` first is fine. `init` **merges** into a `package.json` you
|
|
231
|
+
already have rather than skipping it: missing dependencies, devDependencies and
|
|
232
|
+
scripts are added, and anything you already set is left alone — your pinned
|
|
233
|
+
version and your own `test` script are decisions, not omissions.
|
|
234
|
+
|
|
235
|
+
Deliberate exceptions:
|
|
236
|
+
|
|
237
|
+
- `npm init -y`'s stub `test` script (`echo "Error: no test specified" && exit
|
|
238
|
+
1`) is replaced. It is npm's placeholder, not a choice, and keeping it left
|
|
239
|
+
`ekanos test` failing on a fresh scaffold.
|
|
240
|
+
- `"type"` is set to `module` even if you declare `"commonjs"`, with a loud
|
|
241
|
+
note saying it changed. `npm init -y` writes `"commonjs"` explicitly, so that
|
|
242
|
+
is the mainline rather than an edge case — and the files this scaffolder
|
|
243
|
+
writes are ESM TypeScript that cannot load under it. Leaving it would mean
|
|
244
|
+
writing files that do not run. If the project has CommonJS sources of its
|
|
245
|
+
own, revert that line and put your integration in its own package: the two
|
|
246
|
+
module systems cannot share one `package.json`.
|
|
247
|
+
|
|
248
|
+
Every verb after `init` fails fast with `precondition_failed` (exit 9) if
|
|
249
|
+
`"type"` is not `module`, naming the file and the fix — so missing the note
|
|
250
|
+
costs you one clear error rather than a module-resolution failure from inside
|
|
251
|
+
Node or Next.
|
|
252
|
+
|
|
253
|
+
Anything `init` does not do is reported with a hint saying what to do about it —
|
|
254
|
+
`data.skipped[]` carries `{ file, reason, hint }`, and `data.manifest.notes[]`
|
|
255
|
+
carries the same for manifest fields it could not resolve.
|
|
256
|
+
|
|
257
|
+
## Authentication
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
ekanos login --host https://your-fusion-host # or set EKANOS_HOST
|
|
261
|
+
ekanos whoami --host https://your-fusion-host
|
|
262
|
+
ekanos logout --host https://your-fusion-host
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
`login` uses the **OAuth 2.0 device authorization flow**: it prints a short code
|
|
266
|
+
and a URL to **stderr**, and you approve the request in a browser on any device.
|
|
267
|
+
That is deliberate — a loopback/PKCE login needs a browser on the same machine
|
|
268
|
+
and a bindable localhost port, which is exactly what you do not have over SSH or
|
|
269
|
+
inside a container.
|
|
270
|
+
|
|
271
|
+
There is nothing to configure on your side. The CLI asks the host for its Auth0
|
|
272
|
+
domain, client id and scope (`GET /api/auth/cli-config`), so one binary works
|
|
273
|
+
against a sandbox and production.
|
|
274
|
+
|
|
275
|
+
### Where credentials live
|
|
276
|
+
|
|
277
|
+
`~/.ekanos/credentials.json`, mode **0600**, inside a `~/.ekanos` directory
|
|
278
|
+
forced to **0700**, keyed by host so several deployments can be held at once.
|
|
279
|
+
Set `EKANOS_HOME` to relocate it (containers, CI).
|
|
280
|
+
|
|
281
|
+
If either is found group- or world-accessible, the CLI tightens the permissions
|
|
282
|
+
and warns on stderr — it does not refuse to run, but it does tell you, and you
|
|
283
|
+
should assume the token was exposed and re-run `ekanos logout && ekanos login`.
|
|
284
|
+
|
|
285
|
+
The file holds the rotating Auth0 refresh token plus a short-lived Fusion JWT.
|
|
286
|
+
No token is ever printed: not by `whoami`, not in an error, not in a `--json`
|
|
287
|
+
envelope, not at any log level.
|
|
288
|
+
|
|
289
|
+
### Idempotence
|
|
290
|
+
|
|
291
|
+
- `login` with a session that still works reports `already-authenticated` and
|
|
292
|
+
never opens a browser. `--force` starts a fresh login regardless.
|
|
293
|
+
- `logout` twice succeeds; the second run reports `cleared: false`.
|
|
294
|
+
- `logout` revokes at Auth0 first, but a failed revocation never stops the local
|
|
295
|
+
delete — it is reported as `revoked: false` with a warning on stderr.
|
|
296
|
+
|
|
297
|
+
### Rotation safety
|
|
298
|
+
|
|
299
|
+
Auth0 rotates the refresh token the moment the server's exchange succeeds, so
|
|
300
|
+
from that instant the replacement is the only copy in existence — and
|
|
301
|
+
`/api/auth/cli-refresh` can still fail afterwards. It therefore returns the
|
|
302
|
+
rotated token on its error responses too, and the CLI persists it the moment it
|
|
303
|
+
is seen, before surfacing any error. A transient upstream blip costs you a
|
|
304
|
+
retry, not your session.
|
|
305
|
+
|
|
306
|
+
Two hazards, both handled and worth telling apart:
|
|
307
|
+
|
|
308
|
+
- **Rotate-then-fail** (single process): the refresh rotates and a later step
|
|
309
|
+
fails. Handled as above — retry, no re-login.
|
|
310
|
+
- **Concurrent rotation** (two processes sharing one credentials file): both
|
|
311
|
+
present the same token, Auth0 honours the first, the second gets a clean
|
|
312
|
+
exit 4. No file lock; re-run `ekanos login`.
|
|
313
|
+
|
|
314
|
+
### Exit codes to branch on
|
|
315
|
+
|
|
316
|
+
`4` (auth-required) means "run `ekanos login`" — no session, an expired one that
|
|
317
|
+
could not be renewed, or a revoked one. `8` (network) means the host was
|
|
318
|
+
unreachable, or the refresh rotated but could not complete; it is **not** a
|
|
319
|
+
signal to log in again — retry it.
|
|
320
|
+
|
|
321
|
+
### What this does and does not grant
|
|
322
|
+
|
|
323
|
+
`login` establishes **identity**. What that identity is permitted to do is a
|
|
324
|
+
separate concern, handled by Fusion's own authorization; the CLI adds no
|
|
325
|
+
permissions of its own and no path to acquire any.
|
|
326
|
+
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export interface StoredSession {
|
|
2
|
+
/** The Auth0 refresh token. Rotating — replaced on every refresh. */
|
|
3
|
+
auth0RefreshToken: string;
|
|
4
|
+
/** The minted Fusion JWT. Short-lived; re-minted from the refresh token. */
|
|
5
|
+
accessToken: string;
|
|
6
|
+
/** ISO-8601 expiry of `accessToken`, as reported by the server. */
|
|
7
|
+
expiresAt: string;
|
|
8
|
+
userId: string;
|
|
9
|
+
email: string | null;
|
|
10
|
+
/** ISO-8601 timestamp of the last write. */
|
|
11
|
+
updatedAt: string;
|
|
12
|
+
}
|
|
13
|
+
export interface CredentialStoreOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Overrides the home directory. `EKANOS_HOME` is honoured by the caller;
|
|
16
|
+
* tests pass a temp dir. Never read from `process.env` in here so the store
|
|
17
|
+
* is deterministic under test.
|
|
18
|
+
*/
|
|
19
|
+
home: string;
|
|
20
|
+
/** Where permission warnings go. Always stderr in production. */
|
|
21
|
+
warn?: (message: string) => void;
|
|
22
|
+
}
|
|
23
|
+
export declare class CredentialStore {
|
|
24
|
+
private readonly dir;
|
|
25
|
+
private readonly file;
|
|
26
|
+
private readonly warn;
|
|
27
|
+
constructor(options: CredentialStoreOptions);
|
|
28
|
+
/** Absolute path to the credentials file — safe to print. */
|
|
29
|
+
get filePath(): string;
|
|
30
|
+
/**
|
|
31
|
+
* Read the session for `host`, or null when there is none.
|
|
32
|
+
*
|
|
33
|
+
* A file that exists but is unreadable or corrupt is a precondition failure,
|
|
34
|
+
* not a silent "logged out": treating a corrupt file as absent would send the
|
|
35
|
+
* user through a fresh browser login that then overwrites whatever was
|
|
36
|
+
* really in there.
|
|
37
|
+
*/
|
|
38
|
+
read(host: string): StoredSession | null;
|
|
39
|
+
/** Every host with a stored session. Used by `logout --all` and diagnostics. */
|
|
40
|
+
hosts(): string[];
|
|
41
|
+
write(host: string, session: StoredSession): void;
|
|
42
|
+
/**
|
|
43
|
+
* Remove the session for `host`. Returns whether anything was removed, so
|
|
44
|
+
* `logout` can report accurately while still exiting 0 either way — logging
|
|
45
|
+
* out twice is not an error.
|
|
46
|
+
*/
|
|
47
|
+
clear(host: string): boolean;
|
|
48
|
+
/** Remove every session. Returns the number of hosts cleared. */
|
|
49
|
+
clearAll(): number;
|
|
50
|
+
private load;
|
|
51
|
+
/**
|
|
52
|
+
* Write atomically, and never through a mode the umask can widen.
|
|
53
|
+
*
|
|
54
|
+
* `writeFileSync`'s `mode` applies only when the file is CREATED, so a
|
|
55
|
+
* pre-existing world-readable file would keep its mode on every subsequent
|
|
56
|
+
* write. Writing a fresh temp file and renaming over the target sidesteps
|
|
57
|
+
* that, and also means a crash mid-write cannot leave a half-written
|
|
58
|
+
* credentials file behind.
|
|
59
|
+
*/
|
|
60
|
+
private persist;
|
|
61
|
+
/**
|
|
62
|
+
* Create `~/.ekanos` at 0700 — and repair it if it already exists wider.
|
|
63
|
+
*
|
|
64
|
+
* A pre-existing directory is the common case (other Ekanos state may live
|
|
65
|
+
* there), and `mkdir`'s mode is ignored when the directory is already
|
|
66
|
+
* present, so the mode has to be asserted separately every time.
|
|
67
|
+
*/
|
|
68
|
+
private ensureDir;
|
|
69
|
+
/**
|
|
70
|
+
* Warn about and repair an over-permissive credentials file.
|
|
71
|
+
*
|
|
72
|
+
* Repair rather than refuse: a hard refusal (ssh's behaviour) would leave a
|
|
73
|
+
* partner with a CLI that will not run and no obvious fix, and the token is
|
|
74
|
+
* already exposed by the time we notice — narrowing the window beats
|
|
75
|
+
* blocking. The warning goes to stderr so `--json` stdout stays parseable.
|
|
76
|
+
*/
|
|
77
|
+
private auditPermissions;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Resolve the home directory the store lives under. `EKANOS_HOME` exists for
|
|
81
|
+
* containers and CI, where `os.homedir()` can resolve to a non-writable path.
|
|
82
|
+
*/
|
|
83
|
+
export declare function resolveEkanosHome(env?: Record<string, string | undefined>): string;
|
|
84
|
+
/**
|
|
85
|
+
* Canonical key for a host: scheme + authority, no trailing slash, lowercased.
|
|
86
|
+
* Without this, `https://app.example.com` and `https://app.example.com/` would
|
|
87
|
+
* be two different sessions.
|
|
88
|
+
*/
|
|
89
|
+
export declare function normalizeHost(host: string): string;
|
|
90
|
+
/** A printable summary of a session — never includes a token. */
|
|
91
|
+
export declare function describe(session: StoredSession): {
|
|
92
|
+
userId: string;
|
|
93
|
+
email: string | null;
|
|
94
|
+
expiresAt: string;
|
|
95
|
+
updatedAt: string;
|
|
96
|
+
};
|