@ekanos/cli 0.2.3 → 0.3.1

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/README.md CHANGED
@@ -13,20 +13,20 @@ npx @ekanos/cli dev
13
13
 
14
14
  ## Verbs
15
15
 
16
- | Verb | What it does |
17
- |---|---|
18
- | `init` | Scaffold `ekanos.json`, a working `defineIntegration`, a test, a `tsconfig.json`, a vitest config, `.gitignore`, a `package.json` with real published deps, and the agent-context files (`AGENTS.md`, `CLAUDE.md`, a Claude Code skill). Defaults to a new `./<slug>` directory; `--dir .` scaffolds in place, on top of an existing project if there is one. |
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, plus a compact `seats` list (slug + roles) from the same endpoint `sources` uses — degrades to `seats: null` rather than failing if that lookup is unavailable. |
25
- | `status` | The read verb: login state for the resolved host, this project's `ekanos.json`, and its submissions on the host (`GET /api/partner/submissions`). Never fails just because you are logged out — it reports `authenticated: false` instead. |
26
- | `sources` | List every Fusion source you hold a developer or source-admin seat on (`GET /api/partner/sources`), with a marker on this project's current publish target. |
27
- | `use` | `ekanos use <source-slug>` — set this project's publish target to a source you hold a seat on. Refuses (`forbidden`, with your actual seats in the hint) for a slug you don't hold a seat on, so a typo can't silently repoint a project. |
28
- | `publish` | Validate, pack and submit the integration to a Fusion host — refusing (`forbidden`) a target you hold no seat on, and confirming an ambiguous multi-seat target before submitting. See [Publish target verification](#publish-target-verification). |
29
- | `upgrade` | Move this toolchain to a coherent target version, with verify-and-rollback. `--check` reports current vs. target and changes nothing; a bare `upgrade` installs, verifies (`validate` + the test script), and rolls back automatically on any failure. See [Keeping the toolchain current](#keeping-the-toolchain-current). |
16
+ | Verb | What it does |
17
+ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
18
+ | `init` | Scaffold `ekanos.json`, a working `defineIntegration`, a test, a `tsconfig.json`, a vitest config, `.gitignore`, a `package.json` with real published deps, and the agent-context files (`AGENTS.md`, `CLAUDE.md`, a Claude Code skill). Defaults to a new `./<slug>` directory; `--dir .` scaffolds in place, on top of an existing project if there is one. |
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, plus a compact `seats` list (slug + roles) from the same endpoint `sources` uses — degrades to `seats: null` rather than failing if that lookup is unavailable. |
25
+ | `status` | The read verb: login state for the resolved host, this project's `ekanos.json`, and its submissions on the host (`GET /api/partner/submissions`). Never fails just because you are logged out — it reports `authenticated: false` instead. |
26
+ | `sources` | List every Fusion source you hold a developer or source-admin seat on (`GET /api/partner/sources`), with a marker on this project's current publish target. |
27
+ | `use` | `ekanos use <source-slug>` — set this project's publish target to a source you hold a seat on. Refuses (`forbidden`, with your actual seats in the hint) for a slug you don't hold a seat on, so a typo can't silently repoint a project. |
28
+ | `publish` | Validate, pack and submit the integration to a Fusion host — refusing (`forbidden`) a target you hold no seat on, and confirming an ambiguous multi-seat target before submitting. See [Publish target verification](#publish-target-verification). |
29
+ | `upgrade` | Move this toolchain to a coherent target version, with verify-and-rollback. `--check` reports current vs. target and changes nothing; a bare `upgrade` installs, verifies (`validate` + the test script), and rolls back automatically on any failure. See [Keeping the toolchain current](#keeping-the-toolchain-current). |
30
30
 
31
31
  ### Installing: global is fine
32
32
 
@@ -45,12 +45,12 @@ binary you invoked. Set `EKANOS_NO_DELEGATE=1` to opt out for one invocation.
45
45
 
46
46
  ## Global flags
47
47
 
48
- | Flag | What it does |
49
- |---|---|
50
- | `--json` | Force the JSON envelope on, even at a terminal. |
51
- | `--no-json` | Force the human-readable form on, even when piped. |
52
- | `--version` | Print the CLI version and exit 0. Works with or without a verb. |
53
- | `--help` | Print usage. Also available per-verb (`ekanos validate --help`). |
48
+ | Flag | What it does |
49
+ | ----------- | ---------------------------------------------------------------- |
50
+ | `--json` | Force the JSON envelope on, even at a terminal. |
51
+ | `--no-json` | Force the human-readable form on, even when piped. |
52
+ | `--version` | Print the CLI version and exit 0. Works with or without a verb. |
53
+ | `--help` | Print usage. Also available per-verb (`ekanos validate --help`). |
54
54
 
55
55
  Short flags are not supported — the parser rejects them rather than guessing.
56
56
 
@@ -63,7 +63,7 @@ in either direction. In JSON mode **stdout carries exactly one JSON object** —
63
63
  stderr. Errors always carry an imperative `hint`.
64
64
 
65
65
  The CLI never prompts, with **one deliberate exception**: `publish` asks for a
66
- y/N confirm at a real terminal when the publish target is *ambiguous* — an
66
+ y/N confirm at a real terminal when the publish target is _ambiguous_ — an
67
67
  implicit (ekanos.json) source and more than one developer seat. See
68
68
  [Publish target verification](#publish-target-verification). Every other verb,
69
69
  and `publish` itself in JSON/non-TTY mode or with an explicit `--source`, never
@@ -94,10 +94,12 @@ then cross-checks them against each other (colliding slugs, widget ids, tool
94
94
  names), which it cannot do when it can only see one:
95
95
 
96
96
  ```json
97
- { "integrations": [
98
- { "slug": "acme-crm", "entry": "src/crm.ts" },
97
+ {
98
+ "integrations": [
99
+ { "slug": "acme-crm", "entry": "src/crm.ts" },
99
100
  { "slug": "acme-billing", "entry": "src/billing.ts" }
100
- ] }
101
+ ]
102
+ }
101
103
  ```
102
104
 
103
105
  Declare one form or the other, never both. `validate` also checks that each
@@ -107,15 +109,15 @@ than a warning.
107
109
 
108
110
  ### Every field
109
111
 
110
- | Field | Required | Where | What |
111
- |---|---|---|---|
112
- | `slug` | yes, in the single form | top level | kebab-case; must equal the definition's `slug` |
113
- | `entry` | yes, in the single form | top level | path to the module exporting your `defineIntegration(...)` result |
114
- | `integrations[]` | yes, in the list form | top level | `{ slug, entry }` per integration, same rules, slugs unique |
115
- | `sourceGlobs` | no | **top level only** | extra Tailwind `@source` globs; project-relative, no `..` |
116
- | `source` | no | top level | the Fusion source slug this project publishes to; written by the first successful `publish` |
117
- | `host` | no | top level | the Fusion host (http(s) origin) this project publishes to; written by the first successful `publish`, read by every host-taking verb |
118
- | `$schema` | no | top level | ignored, for editor tooling |
112
+ | Field | Required | Where | What |
113
+ | ---------------- | ----------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
114
+ | `slug` | yes, in the single form | top level | kebab-case; must equal the definition's `slug` |
115
+ | `entry` | yes, in the single form | top level | path to the module exporting your `defineIntegration(...)` result |
116
+ | `integrations[]` | yes, in the list form | top level | `{ slug, entry }` per integration, same rules, slugs unique |
117
+ | `sourceGlobs` | no | **top level only** | extra Tailwind `@source` globs; project-relative, no `..` |
118
+ | `source` | no | top level | the Fusion source slug this project publishes to; written by the first successful `publish` |
119
+ | `host` | no | top level | the Fusion host (http(s) origin) this project publishes to; written by the first successful `publish`, read by every host-taking verb |
120
+ | `$schema` | no | top level | ignored, for editor tooling |
119
121
 
120
122
  The file is **strict**: an unrecognised key is an error naming the key, not a
121
123
  silent no-op. `sourceGlobs` is deliberately not per-integration — Tailwind scans
@@ -138,6 +140,9 @@ imply an isolation that does not exist.
138
140
  app/[slug]/tile/page.tsx ├─ one-line re-exports of @ekanos/harness/routes
139
141
  app/[slug]/activation/page.tsx
140
142
  app/[slug]/triggers/page.tsx ─┘
143
+ app/api/integrations/[slug]/[...path]/route.ts
144
+ server — injects your registry into @ekanos/harness/api
145
+ (live-mode OAuth loop, storage bridge, trigger route)
141
146
  next.config.mjs transpiles @ekanos/harness, aliases the workspace mock
142
147
  postcss.config.mjs @tailwindcss/postcss
143
148
  tsconfig.json no baseUrl, no paths
@@ -174,14 +179,63 @@ default wildcard bind hands all of that to anyone who can reach the port.
174
179
  warning saying what is now reachable, and the JSON envelope carries
175
180
  `server.exposed: true` so a script can refuse to run that way.
176
181
 
182
+ The shell's API routes (below) are stricter than the pages: they hold your
183
+ local OAuth client secret and the tokens `onTokens` stored, and they run your
184
+ handlers on request, so they **refuse any request whose `Host` is not
185
+ loopback** unless `EKANOS_HARNESS_EXPOSED=1`. `dev --host <non-loopback>` sets
186
+ that for the server it starts (and prefixes it on the command it prints with
187
+ `--no-start`), after the warning. Starting Next by hand with a wildcard bind
188
+ gets the refusal, not the exposure — which is the point.
189
+
190
+ ### Local OAuth — the real loop, on localhost
191
+
192
+ An integration that declares `oauth` in `defineIntegration()` can run the whole
193
+ authorize → callback → code-exchange → `onTokens` loop against **your own
194
+ developer app**, in live mode, with no Fusion host involved:
195
+
196
+ 1. Switch the toolbar's **Data** dropdown to **Live API** (the entry needs a
197
+ `live` block). An integration whose widgets never call the vendor from the
198
+ browser — the OAuth shape — can declare `live: { default: 'live' }` and the
199
+ harness starts there; a choice made at the toolbar still wins.
200
+ 2. Open the Activation surface. The **Local OAuth** panel starts with a
201
+ checklist for the part only a human can do in the provider's developer
202
+ console (create the app, add the redirect URI
203
+ `http://localhost:3100/api/integrations/<slug>/oauth/callback`, enable the
204
+ declared scopes, copy the client id and secret) — derived from your
205
+ declaration, with a console link and provider-specific notes if your harness
206
+ entry sets `oauth.setup` — and then a form with one field per secret name
207
+ your definition declares
208
+ (`oauth.credentials.*SecretName`, every signed webhook's `secretName`).
209
+ Paste the values and **Save credentials**. They are written server-side to
210
+ **`.ekanos/local-secrets.json`** — gitignored, never packed, read on every
211
+ request — and never sent back to the browser: a saved key shows as `filled`
212
+ beside an empty field, and a blank field keeps what is saved. (Editing the
213
+ file by hand works too.)
214
+ 3. Click **Connect**. The shell's route builds the authorize URL (PKCE when
215
+ declared), the provider sends you back, the route exchanges the code through
216
+ `ctx.fetch` — so an undeclared token-endpoint origin fails with
217
+ `EgressDeniedError` exactly as on the host — and calls your `onTokens` on a
218
+ **server-held context**. `oauth/status` then reports connected, the SDK's
219
+ `OAuthActivationForm` auto-activates, and `onActivate` runs on that same
220
+ context.
221
+ 4. Your widgets read what `onTokens`, `onActivate` and your schedules wrote
222
+ through the ordinary storage route, unchanged.
223
+
224
+ The connection lives in the dev server's memory: restarting `next dev` forgets
225
+ it and you Connect again. Editing a value in the secrets file rebuilds the
226
+ context and forgets the connection too — a different client id invalidates the
227
+ tokens anyway. In **fixtures** mode none of this runs: the status route is
228
+ answered from `oauth.connected` in your harness entry (connected by default),
229
+ so widgets render without a round trip and nothing leaves the browser.
230
+
177
231
  ### Who owns what
178
232
 
179
- | Path | Owner | You edit it? |
180
- |---|---|---|
181
- | `harness.config.ts` (project root) | **you** | **yes — the only one.** Written once, git-tracked, never touched again. |
182
- | your integration source | **you** | yes |
183
- | `.ekanos/harness/**` | generated | no — gitignored, rewritten on every run |
184
- | `@ekanos/harness` | us | no — it is an ordinary dependency |
233
+ | Path | Owner | You edit it? |
234
+ | ---------------------------------- | --------- | ----------------------------------------------------------------------- |
235
+ | `harness.config.ts` (project root) | **you** | **yes — the only one.** Written once, git-tracked, never touched again. |
236
+ | your integration source | **you** | yes |
237
+ | `.ekanos/harness/**` | generated | no — gitignored, rewritten on every run |
238
+ | `@ekanos/harness` | us | no — it is an ordinary dependency |
185
239
 
186
240
  That split is the point: a harness bugfix ships as an `@ekanos/harness` version
187
241
  bump you pick up with `pnpm up`, with zero edits to any file you own. Every
@@ -219,8 +273,11 @@ integration lives somewhere else, add globs to `ekanos.json` — do NOT edit the
219
273
  stylesheet, which is regenerated on every run:
220
274
 
221
275
  ```json
222
- { "slug": "acme-crm", "entry": "src/integration.ts",
223
- "sourceGlobs": ["vendor/acme/**/*.{ts,tsx}"] }
276
+ {
277
+ "slug": "acme-crm",
278
+ "entry": "src/integration.ts",
279
+ "sourceGlobs": ["vendor/acme/**/*.{ts,tsx}"]
280
+ }
224
281
  ```
225
282
 
226
283
  A glob Tailwind is not pointed at costs you a silently unstyled integration
@@ -244,6 +301,10 @@ upgrade.
244
301
  and a hash of the templates, so a change to any of them regenerates the shell
245
302
  and `dev` says which one moved.
246
303
 
304
+ Contract history: **1** was the original ten-file shell; **2** (CLI 0.3 /
305
+ harness 0.2) added `app/api/integrations/[slug]/[...path]/route.ts`, the shell's
306
+ one server route, injecting the registry into `@ekanos/harness/api`.
307
+
247
308
  **For maintainers:** propagation is byte-driven — `dev` rewrites a generated
248
309
  file whenever its content differs, marker or not. So a change to the SHELL
249
310
  ships in an `@ekanos/cli` release, never a harness one: bumping
@@ -325,7 +386,7 @@ version and your own `test` script are decisions, not omissions.
325
386
  Deliberate exceptions:
326
387
 
327
388
  - `npm init -y`'s stub `test` script (`echo "Error: no test specified" && exit
328
- 1`) is replaced. It is npm's placeholder, not a choice, and keeping it left
389
+ 1`) is replaced. It is npm's placeholder, not a choice, and keeping it left
329
390
  `ekanos test` failing on a fresh scaffold.
330
391
  - `"type"` is set to `module` even if you declare `"commonjs"`, with a loud
331
392
  note saying it changed. `npm init -y` writes `"commonjs"` explicitly, so that
@@ -365,7 +426,7 @@ host in this order:
365
426
 
366
427
  `login` is the exception: it resolves from `--host`, else `EKANOS_HOST`, else
367
428
  the sole stored host — **never** from `ekanos.json`. Login is the one verb
368
- that *creates* credentials, and `ekanos.json` is a committed file; if login
429
+ that _creates_ credentials, and `ekanos.json` is a committed file; if login
369
430
  honored it, anyone could point your device flow at their own tenant just by
370
431
  editing a file in a repo you clone. Every other verb is safe reading it,
371
432
  because their bearer tokens are looked up per-host from the credential store —
@@ -464,13 +525,13 @@ never silently written.
464
525
  `ekanos.json`), then — **before packing or submitting anything** — fetches your
465
526
  seat list and applies these rules:
466
527
 
467
- | Source came from | Seats held | Behavior |
468
- |---|---|---|
469
- | any | not among your seats | `forbidden` (exit 5): refuses before packing/submitting; the hint lists your actual seats |
470
- | `--source` flag (this run) | any | proceeds immediately — an explicit flag is consent |
471
- | `ekanos.json` (implicit) | exactly one | proceeds, printing "Publishing to `<name>` (`<slug>`) — your only developer seat" |
472
- | `ekanos.json` (implicit) | more than one, human mode **and** a real terminal on stdin | prints the target and asks for an explicit `y/N` confirm; `--yes` skips it |
473
- | `ekanos.json` (implicit) | more than one, anything else (JSON mode, `--json`, piped, an agent, **or** human mode with non-interactive stdin — a cron job, a wrapper script) | **never prompts** — proceeds only with `--yes`; otherwise `validation` (exit 3) with the seat list and a hint naming `--source`, `--yes`, and `ekanos use` |
528
+ | Source came from | Seats held | Behavior |
529
+ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
530
+ | any | not among your seats | `forbidden` (exit 5): refuses before packing/submitting; the hint lists your actual seats |
531
+ | `--source` flag (this run) | any | proceeds immediately — an explicit flag is consent |
532
+ | `ekanos.json` (implicit) | exactly one | proceeds, printing "Publishing to `<name>` (`<slug>`) — your only developer seat" |
533
+ | `ekanos.json` (implicit) | more than one, human mode **and** a real terminal on stdin | prints the target and asks for an explicit `y/N` confirm; `--yes` skips it |
534
+ | `ekanos.json` (implicit) | more than one, anything else (JSON mode, `--json`, piped, an agent, **or** human mode with non-interactive stdin — a cron job, a wrapper script) | **never prompts** — proceeds only with `--yes`; otherwise `validation` (exit 3) with the seat list and a hint naming `--source`, `--yes`, and `ekanos use` |
474
535
 
475
536
  A successful publish's envelope carries `source: { id, slug, name }` — the
476
537
  verified target, not just the slug string — so a script or an agent can log
@@ -480,4 +541,3 @@ exactly which source a submission landed on.
480
541
  Either pass `--source <slug>` explicitly, or run `ekanos sources` first and
481
542
  choose deliberately. `--yes` exists for a human's already-confirmed intent and
482
543
  for CI pipelines pinned to one source — not as a way to silence the check.
483
-
@@ -21,6 +21,16 @@ const DEFAULT_PORT = 3100;
21
21
  const DEFAULT_HOST = '127.0.0.1';
22
22
  /** Addresses that are only reachable from this machine. */
23
23
  const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
24
+ /**
25
+ * Set for the dev server when `--host` binds beyond loopback. The shell's
26
+ * API routes (`@ekanos/harness/api`) refuse non-loopback `Host` headers
27
+ * unless this is `1`: they hold the partner's local OAuth credentials and
28
+ * run handlers on request, so exposing them has to be a decision — this one,
29
+ * made after the warning below — rather than a side effect of the bind.
30
+ */
31
+ const EXPOSED_ENV = 'EKANOS_HARNESS_EXPOSED';
32
+ /** Where the harness generates, and reads, local dev credentials. */
33
+ const LOCAL_SECRETS_PATH = path.join('.ekanos', 'local-secrets.json');
24
34
  /**
25
35
  * `dev` — scaffold the local harness shell into the partner's project, then
26
36
  * run it with their own Next.
@@ -96,17 +106,26 @@ export async function runDev(ctx, args) {
96
106
  ctx.log(` updated ${HARNESS_DIR}/${file}`);
97
107
  if (gitignoreUpdated)
98
108
  ctx.log(' updated .gitignore (.ekanos/)');
109
+ ctx.log(` live-mode OAuth: the harness generates ${LOCAL_SECRETS_PATH} on first ` +
110
+ 'use — fill it in and follow "Local OAuth" on the Activation surface.');
99
111
  // `--hostname` is passed ALWAYS, never left to Next's default, so the URL
100
112
  // we print is the URL that is actually served.
101
113
  const serverArgs = ['dev', '--port', String(port), '--hostname', host];
102
- const command = formatCommand(pm.bin, execArgs(pm, 'next', serverArgs));
114
+ const serverEnv = exposed
115
+ ? { [EXPOSED_ENV]: '1' }
116
+ : {};
117
+ const command = (exposed ? `${EXPOSED_ENV}=1 ` : '') +
118
+ formatCommand(pm.bin, execArgs(pm, 'next', serverArgs));
103
119
  const url = `http://${displayHost(host)}:${port}`;
104
120
  const scaffold = Object.assign({ slug: loaded.primary.slug, integrations: loaded.integrations.map((i) => i.slug), projectDir: loaded.projectDir, harnessDir: shell.harnessDir, harnessVersion, previousVersion: shell.previousVersion, previousMarker: shell.previousMarker, stale: shell.stale, staleReasons: shell.staleReasons, sourceGlobs: (_b = loaded.project.sourceGlobs) !== null && _b !== void 0 ? _b : [], created: shell.created, updated: shell.updated, unchanged: shell.unchanged, configBootstrapped: config.bootstrapped, configPath: config.configPath, gitignoreUpdated }, (notice ? { toolchain: notice } : {}));
105
121
  if (exposed) {
106
122
  ctx.warn(`WARNING: --host ${host} exposes the harness beyond this machine. It ` +
107
123
  'has no authentication, and it renders your fixtures and — in live ' +
108
- 'mode — real responses from your vendor API. Anyone who can reach ' +
109
- `${url} can read all of it. Omit --host to bind ${DEFAULT_HOST}.`);
124
+ 'mode — real responses from your vendor API. Its API routes hold the ' +
125
+ `client secret from ${LOCAL_SECRETS_PATH}, the tokens onTokens stored, ` +
126
+ 'and run your handlers on request; they refuse non-loopback callers ' +
127
+ `unless ${EXPOSED_ENV}=1, which this sets. Anyone who can reach ` +
128
+ `${url} can read and run all of it. Omit --host to bind ${DEFAULT_HOST}.`);
110
129
  }
111
130
  // A JSON caller is an agent or a pipe. Handing it a dev server that never
112
131
  // exits would hang its tool call forever and it would never see the
@@ -122,6 +141,7 @@ export async function runDev(ctx, args) {
122
141
  bin: pm.bin,
123
142
  argv: execArgs(pm, 'next', serverArgs),
124
143
  cwd: shell.harnessDir,
144
+ env: serverEnv,
125
145
  });
126
146
  const data = Object.assign(Object.assign({}, scaffold), { server: { started: true, command, url, port, host, exposed, exitCode } });
127
147
  if (exitCode === 0) {
@@ -143,6 +163,7 @@ function spawnServer(params) {
143
163
  var _a, _b;
144
164
  const child = spawn(params.bin, params.argv, {
145
165
  cwd: params.cwd,
166
+ env: Object.assign(Object.assign({}, process.env), params.env),
146
167
  // stdin inherited so Next's own key handling works; stdout/stderr piped
147
168
  // so we can route them and keep OUR stdout reserved for the envelope.
148
169
  stdio: ['inherit', 'pipe', 'pipe'],
@@ -1 +1 @@
1
- {"version":3,"file":"dev.js","sourceRoot":"","sources":["../../src/commands/dev.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn } from 'node:child_process';\nimport * as path from 'node:path';\n\nimport { assertHarnessCompatible } from '../compatibility';\nimport type { CliContext } from '../context';\nimport { preconditionError, usageError } from '../errors';\nimport {\n HARNESS_DIR,\n HARNESS_PACKAGE,\n assertShellDirIsOurs,\n bootstrapHarnessConfig,\n ensureGitignoreEntry,\n missingDependencies,\n readInstalledHarness,\n scaffoldShell,\n} from '../harness-scaffold';\nimport {\n detectPackageManager,\n execArgs,\n formatCommand,\n installCommand,\n} from '../package-manager';\nimport { loadProject } from '../project';\nimport { announceUpdate, checkForUpdate } from '../update-notice';\n\nexport interface DevArgs {\n /** Project directory (contains ekanos.json). Defaults to cwd. */\n dir: string;\n /** Port for the dev server. */\n port?: string;\n /**\n * Interface to bind. Defaults to loopback — see `resolveHost`. Anything\n * else is an explicit, warned-about decision to expose the harness.\n *\n * NOTE: unrelated to the Fusion HOST the update notice checks against —\n * this is the local bind address for `next dev`, never a Fusion origin.\n */\n host?: string;\n /** Rewrite every generated file even when its bytes already match. */\n force: boolean;\n /**\n * Explicit `--start` / `--no-start`. Undefined means \"decide from the\n * output mode\": a JSON caller is an agent or a pipe and must not be handed\n * a process that never exits.\n */\n start?: boolean;\n env?: Record<string, string | undefined>;\n}\n\nconst DEFAULT_PORT = 3100;\n\n/**\n * Loopback, NOT Next's default.\n *\n * `next dev` binds 0.0.0.0 unless told otherwise, so the harness would be\n * reachable by anyone on the same network — a coffee-shop Wi-Fi, a shared\n * office VLAN — while we printed \"http://localhost:3100\" and implied\n * otherwise. It renders a partner's integration with their fixtures and, in\n * live mode, whatever their vendor API returns; there is no auth in front of\n * any of it. Binding loopback by default costs a partner nothing and makes\n * exposure a thing they ask for.\n */\nconst DEFAULT_HOST = '127.0.0.1';\n\n/** Addresses that are only reachable from this machine. */\nconst LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);\n\n/**\n * `dev` — scaffold the local harness shell into the partner's project, then\n * run it with their own Next.\n *\n * The shape of this verb follows from one rule: we must be able to ship a\n * harness bugfix as an `@ekanos/harness` version bump that a partner picks up\n * with `pnpm up`, with zero edits to any file they own. So everything under\n * `.ekanos/` is generated, gitignored and rewritten freely, `harness.config.ts`\n * at the project root is written once and never touched again, and all actual\n * harness behaviour lives in the installed package.\n *\n * Two idempotency properties matter and are both tested: a second run produces\n * byte-identical files and rewrites nothing (so Next's watcher stays quiet),\n * and `.gitignore` never grows a duplicate line.\n */\nexport async function runDev(ctx: CliContext, args: DevArgs): Promise<number> {\n const loaded = loadProject(args.dir);\n const port = parsePort(args.port);\n const host = parseHost(args.host);\n const exposed = !LOOPBACK_HOSTS.has(host);\n const pm = detectPackageManager(loaded.projectDir);\n\n const notice = await checkForUpdate({ env: args.env ?? process.env });\n announceUpdate(ctx, notice);\n\n // Fail before writing anything: the shell declares no dependencies of its\n // own, so a missing one would otherwise surface as an opaque Next\n // resolution error minutes later.\n const missing = missingDependencies(loaded.projectDir);\n if (missing.length > 0) {\n throw preconditionError(\n `${missing.length} package${missing.length === 1 ? '' : 's'} the ` +\n `harness needs ${missing.length === 1 ? 'is' : 'are'} not installed ` +\n `in ${loaded.projectDir}: ${missing.join(', ')}.`,\n `Run \"${installCommand(pm, missing)}\" in ${loaded.projectDir}, then ` +\n 're-run \"ekanos dev\".',\n );\n }\n\n const harness = readInstalledHarness(loaded.projectDir);\n const harnessVersion = harness?.version;\n if (!harnessVersion) {\n throw preconditionError(\n `${HARNESS_PACKAGE} is installed but declares no version.`,\n `Reinstall it with \"${installCommand(pm, [HARNESS_PACKAGE])}\" in ` +\n `${loaded.projectDir}, then re-run \"ekanos dev\".`,\n );\n }\n\n // The templates ship with the CLI and the code they import ships with the\n // harness, so a partner can upgrade one without the other. Name that\n // mismatch here rather than letting it surface later as a module-resolution\n // error inside a shell we cheerfully certified as current.\n assertHarnessCompatible({\n harnessVersion,\n harnessManifest: harness?.manifest,\n installHint: (spec) => installCommand(pm, [spec]),\n });\n\n // Validate the shell target BEFORE writing anything at all. Bootstrapping\n // harness.config.ts first and only then discovering that .ekanos/harness is\n // a file or someone else's tree would leave the project half-scaffolded.\n assertShellDirIsOurs(path.join(loaded.projectDir, HARNESS_DIR), args.force);\n\n const config = bootstrapHarnessConfig(loaded.projectDir, loaded.integrations);\n const shell = scaffoldShell({\n projectDir: loaded.projectDir,\n slug: loaded.primary.slug,\n harnessVersion,\n force: args.force,\n sourceGlobs: loaded.project.sourceGlobs,\n });\n const gitignoreUpdated = ensureGitignoreEntry(loaded.projectDir);\n\n if (shell.stale) {\n ctx.log(\n `Regenerating ${HARNESS_DIR} — ${shell.staleReasons.join('; ')}. ` +\n `Nothing you own changed; ${config.configPath} is untouched.`,\n );\n }\n\n if (config.bootstrapped) {\n ctx.log(` created harness.config.ts (yours — edit it, we never will)`);\n }\n for (const file of shell.created) ctx.log(` created ${HARNESS_DIR}/${file}`);\n for (const file of shell.updated) ctx.log(` updated ${HARNESS_DIR}/${file}`);\n if (gitignoreUpdated) ctx.log(' updated .gitignore (.ekanos/)');\n\n // `--hostname` is passed ALWAYS, never left to Next's default, so the URL\n // we print is the URL that is actually served.\n const serverArgs = ['dev', '--port', String(port), '--hostname', host];\n const command = formatCommand(pm.bin, execArgs(pm, 'next', serverArgs));\n const url = `http://${displayHost(host)}:${port}`;\n\n const scaffold = {\n slug: loaded.primary.slug,\n integrations: loaded.integrations.map((i) => i.slug),\n projectDir: loaded.projectDir,\n harnessDir: shell.harnessDir,\n harnessVersion,\n previousVersion: shell.previousVersion,\n previousMarker: shell.previousMarker,\n stale: shell.stale,\n staleReasons: shell.staleReasons,\n sourceGlobs: loaded.project.sourceGlobs ?? [],\n created: shell.created,\n updated: shell.updated,\n unchanged: shell.unchanged,\n configBootstrapped: config.bootstrapped,\n configPath: config.configPath,\n gitignoreUpdated,\n ...(notice ? { toolchain: notice } : {}),\n };\n\n if (exposed) {\n ctx.warn(\n `WARNING: --host ${host} exposes the harness beyond this machine. It ` +\n 'has no authentication, and it renders your fixtures and — in live ' +\n 'mode — real responses from your vendor API. Anyone who can reach ' +\n `${url} can read all of it. Omit --host to bind ${DEFAULT_HOST}.`,\n );\n }\n\n // A JSON caller is an agent or a pipe. Handing it a dev server that never\n // exits would hang its tool call forever and it would never see the\n // envelope, so JSON mode scaffolds and reports the command instead of\n // running it. `--start` overrides; `--no-start` opts out in a terminal.\n const shouldStart = args.start ?? !ctx.jsonMode;\n if (!shouldStart) {\n return ctx.succeed(\n {\n ...scaffold,\n server: { started: false, command, url, port, host, exposed },\n },\n `dev: harness ready in ${shell.harnessDir}. Start it with: ${command}`,\n );\n }\n\n ctx.log(`Starting the harness on ${url} (${command})…`);\n const exitCode = await spawnServer({\n ctx,\n bin: pm.bin,\n argv: execArgs(pm, 'next', serverArgs),\n cwd: shell.harnessDir,\n });\n\n const data = {\n ...scaffold,\n server: { started: true, command, url, port, host, exposed, exitCode },\n };\n\n if (exitCode === 0) {\n return ctx.succeed(data, `dev: harness stopped cleanly.`);\n }\n\n return ctx.failWith({\n code: 'dev_server_failed',\n message: `\"${command}\" exited with code ${exitCode}.`,\n hint:\n 'Read the Next output on stderr. If it cannot resolve @ekanos/harness ' +\n `or next, reinstall dependencies in ${loaded.projectDir}; if a route ` +\n `fails to compile, delete ${shell.harnessDir} and re-run \"ekanos dev\".`,\n // Transparent passthrough of Next's own exit code.\n exitCode,\n data,\n });\n}\n\nfunction spawnServer(params: {\n ctx: CliContext;\n bin: string;\n argv: string[];\n cwd: string;\n}): Promise<number> {\n return new Promise<number>((resolve, reject) => {\n const child = spawn(params.bin, params.argv, {\n cwd: params.cwd,\n // stdin inherited so Next's own key handling works; stdout/stderr piped\n // so we can route them and keep OUR stdout reserved for the envelope.\n stdio: ['inherit', 'pipe', 'pipe'],\n });\n\n child.stdout?.on('data', (chunk: Buffer) => {\n (params.ctx.jsonMode ? process.stderr : process.stdout).write(chunk);\n });\n child.stderr?.on('data', (chunk: Buffer) => {\n process.stderr.write(chunk);\n });\n\n child.on('error', (error) => reject(error));\n child.on('close', (code) => resolve(code ?? 1));\n });\n}\n\n/**\n * The hostname to show a human. `127.0.0.1` is spelled `localhost` because\n * that is what people type; a wildcard bind is spelled `localhost` too, but\n * only after `exposed` has already been warned about — printing `0.0.0.0` as\n * a URL is not useful.\n */\nfunction displayHost(host: string): string {\n if (host === '127.0.0.1' || host === '0.0.0.0' || host === '::') {\n return 'localhost';\n }\n if (host === '::1') return '[::1]';\n return host;\n}\n\nfunction parseHost(raw: string | undefined): string {\n if (raw === undefined) return DEFAULT_HOST;\n\n const host = raw.trim();\n if (host.length === 0) {\n throw usageError(\n 'Invalid --host \"\".',\n `Pass an interface to bind, e.g. \"--host 0.0.0.0\" to expose the ` +\n `harness on your network, or omit it to bind ${DEFAULT_HOST}.`,\n );\n }\n return host;\n}\n\nfunction parsePort(raw: string | undefined): number {\n if (raw === undefined) return DEFAULT_PORT;\n\n const port = Number(raw);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n throw usageError(\n `Invalid --port \"${raw}\".`,\n 'Pass an integer between 1 and 65535, e.g. \"--port 3100\".',\n );\n }\n return port;\n}\n"]}
1
+ {"version":3,"file":"dev.js","sourceRoot":"","sources":["../../src/commands/dev.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn } from 'node:child_process';\nimport * as path from 'node:path';\n\nimport { assertHarnessCompatible } from '../compatibility';\nimport type { CliContext } from '../context';\nimport { preconditionError, usageError } from '../errors';\nimport {\n HARNESS_DIR,\n HARNESS_PACKAGE,\n assertShellDirIsOurs,\n bootstrapHarnessConfig,\n ensureGitignoreEntry,\n missingDependencies,\n readInstalledHarness,\n scaffoldShell,\n} from '../harness-scaffold';\nimport {\n detectPackageManager,\n execArgs,\n formatCommand,\n installCommand,\n} from '../package-manager';\nimport { loadProject } from '../project';\nimport { announceUpdate, checkForUpdate } from '../update-notice';\n\nexport interface DevArgs {\n /** Project directory (contains ekanos.json). Defaults to cwd. */\n dir: string;\n /** Port for the dev server. */\n port?: string;\n /**\n * Interface to bind. Defaults to loopback — see `resolveHost`. Anything\n * else is an explicit, warned-about decision to expose the harness.\n *\n * NOTE: unrelated to the Fusion HOST the update notice checks against —\n * this is the local bind address for `next dev`, never a Fusion origin.\n */\n host?: string;\n /** Rewrite every generated file even when its bytes already match. */\n force: boolean;\n /**\n * Explicit `--start` / `--no-start`. Undefined means \"decide from the\n * output mode\": a JSON caller is an agent or a pipe and must not be handed\n * a process that never exits.\n */\n start?: boolean;\n env?: Record<string, string | undefined>;\n}\n\nconst DEFAULT_PORT = 3100;\n\n/**\n * Loopback, NOT Next's default.\n *\n * `next dev` binds 0.0.0.0 unless told otherwise, so the harness would be\n * reachable by anyone on the same network — a coffee-shop Wi-Fi, a shared\n * office VLAN — while we printed \"http://localhost:3100\" and implied\n * otherwise. It renders a partner's integration with their fixtures and, in\n * live mode, whatever their vendor API returns; there is no auth in front of\n * any of it. Binding loopback by default costs a partner nothing and makes\n * exposure a thing they ask for.\n */\nconst DEFAULT_HOST = '127.0.0.1';\n\n/** Addresses that are only reachable from this machine. */\nconst LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);\n\n/**\n * Set for the dev server when `--host` binds beyond loopback. The shell's\n * API routes (`@ekanos/harness/api`) refuse non-loopback `Host` headers\n * unless this is `1`: they hold the partner's local OAuth credentials and\n * run handlers on request, so exposing them has to be a decision — this one,\n * made after the warning below — rather than a side effect of the bind.\n */\nconst EXPOSED_ENV = 'EKANOS_HARNESS_EXPOSED';\n\n/** Where the harness generates, and reads, local dev credentials. */\nconst LOCAL_SECRETS_PATH = path.join('.ekanos', 'local-secrets.json');\n\n/**\n * `dev` — scaffold the local harness shell into the partner's project, then\n * run it with their own Next.\n *\n * The shape of this verb follows from one rule: we must be able to ship a\n * harness bugfix as an `@ekanos/harness` version bump that a partner picks up\n * with `pnpm up`, with zero edits to any file they own. So everything under\n * `.ekanos/` is generated, gitignored and rewritten freely, `harness.config.ts`\n * at the project root is written once and never touched again, and all actual\n * harness behaviour lives in the installed package.\n *\n * Two idempotency properties matter and are both tested: a second run produces\n * byte-identical files and rewrites nothing (so Next's watcher stays quiet),\n * and `.gitignore` never grows a duplicate line.\n */\nexport async function runDev(ctx: CliContext, args: DevArgs): Promise<number> {\n const loaded = loadProject(args.dir);\n const port = parsePort(args.port);\n const host = parseHost(args.host);\n const exposed = !LOOPBACK_HOSTS.has(host);\n const pm = detectPackageManager(loaded.projectDir);\n\n const notice = await checkForUpdate({ env: args.env ?? process.env });\n announceUpdate(ctx, notice);\n\n // Fail before writing anything: the shell declares no dependencies of its\n // own, so a missing one would otherwise surface as an opaque Next\n // resolution error minutes later.\n const missing = missingDependencies(loaded.projectDir);\n if (missing.length > 0) {\n throw preconditionError(\n `${missing.length} package${missing.length === 1 ? '' : 's'} the ` +\n `harness needs ${missing.length === 1 ? 'is' : 'are'} not installed ` +\n `in ${loaded.projectDir}: ${missing.join(', ')}.`,\n `Run \"${installCommand(pm, missing)}\" in ${loaded.projectDir}, then ` +\n 're-run \"ekanos dev\".',\n );\n }\n\n const harness = readInstalledHarness(loaded.projectDir);\n const harnessVersion = harness?.version;\n if (!harnessVersion) {\n throw preconditionError(\n `${HARNESS_PACKAGE} is installed but declares no version.`,\n `Reinstall it with \"${installCommand(pm, [HARNESS_PACKAGE])}\" in ` +\n `${loaded.projectDir}, then re-run \"ekanos dev\".`,\n );\n }\n\n // The templates ship with the CLI and the code they import ships with the\n // harness, so a partner can upgrade one without the other. Name that\n // mismatch here rather than letting it surface later as a module-resolution\n // error inside a shell we cheerfully certified as current.\n assertHarnessCompatible({\n harnessVersion,\n harnessManifest: harness?.manifest,\n installHint: (spec) => installCommand(pm, [spec]),\n });\n\n // Validate the shell target BEFORE writing anything at all. Bootstrapping\n // harness.config.ts first and only then discovering that .ekanos/harness is\n // a file or someone else's tree would leave the project half-scaffolded.\n assertShellDirIsOurs(path.join(loaded.projectDir, HARNESS_DIR), args.force);\n\n const config = bootstrapHarnessConfig(loaded.projectDir, loaded.integrations);\n const shell = scaffoldShell({\n projectDir: loaded.projectDir,\n slug: loaded.primary.slug,\n harnessVersion,\n force: args.force,\n sourceGlobs: loaded.project.sourceGlobs,\n });\n const gitignoreUpdated = ensureGitignoreEntry(loaded.projectDir);\n\n if (shell.stale) {\n ctx.log(\n `Regenerating ${HARNESS_DIR} — ${shell.staleReasons.join('; ')}. ` +\n `Nothing you own changed; ${config.configPath} is untouched.`,\n );\n }\n\n if (config.bootstrapped) {\n ctx.log(` created harness.config.ts (yours — edit it, we never will)`);\n }\n for (const file of shell.created) ctx.log(` created ${HARNESS_DIR}/${file}`);\n for (const file of shell.updated) ctx.log(` updated ${HARNESS_DIR}/${file}`);\n if (gitignoreUpdated) ctx.log(' updated .gitignore (.ekanos/)');\n ctx.log(\n ` live-mode OAuth: the harness generates ${LOCAL_SECRETS_PATH} on first ` +\n 'use — fill it in and follow \"Local OAuth\" on the Activation surface.',\n );\n\n // `--hostname` is passed ALWAYS, never left to Next's default, so the URL\n // we print is the URL that is actually served.\n const serverArgs = ['dev', '--port', String(port), '--hostname', host];\n const serverEnv: Record<string, string> = exposed\n ? { [EXPOSED_ENV]: '1' }\n : {};\n const command =\n (exposed ? `${EXPOSED_ENV}=1 ` : '') +\n formatCommand(pm.bin, execArgs(pm, 'next', serverArgs));\n const url = `http://${displayHost(host)}:${port}`;\n\n const scaffold = {\n slug: loaded.primary.slug,\n integrations: loaded.integrations.map((i) => i.slug),\n projectDir: loaded.projectDir,\n harnessDir: shell.harnessDir,\n harnessVersion,\n previousVersion: shell.previousVersion,\n previousMarker: shell.previousMarker,\n stale: shell.stale,\n staleReasons: shell.staleReasons,\n sourceGlobs: loaded.project.sourceGlobs ?? [],\n created: shell.created,\n updated: shell.updated,\n unchanged: shell.unchanged,\n configBootstrapped: config.bootstrapped,\n configPath: config.configPath,\n gitignoreUpdated,\n ...(notice ? { toolchain: notice } : {}),\n };\n\n if (exposed) {\n ctx.warn(\n `WARNING: --host ${host} exposes the harness beyond this machine. It ` +\n 'has no authentication, and it renders your fixtures and — in live ' +\n 'mode — real responses from your vendor API. Its API routes hold the ' +\n `client secret from ${LOCAL_SECRETS_PATH}, the tokens onTokens stored, ` +\n 'and run your handlers on request; they refuse non-loopback callers ' +\n `unless ${EXPOSED_ENV}=1, which this sets. Anyone who can reach ` +\n `${url} can read and run all of it. Omit --host to bind ${DEFAULT_HOST}.`,\n );\n }\n\n // A JSON caller is an agent or a pipe. Handing it a dev server that never\n // exits would hang its tool call forever and it would never see the\n // envelope, so JSON mode scaffolds and reports the command instead of\n // running it. `--start` overrides; `--no-start` opts out in a terminal.\n const shouldStart = args.start ?? !ctx.jsonMode;\n if (!shouldStart) {\n return ctx.succeed(\n {\n ...scaffold,\n server: { started: false, command, url, port, host, exposed },\n },\n `dev: harness ready in ${shell.harnessDir}. Start it with: ${command}`,\n );\n }\n\n ctx.log(`Starting the harness on ${url} (${command})…`);\n const exitCode = await spawnServer({\n ctx,\n bin: pm.bin,\n argv: execArgs(pm, 'next', serverArgs),\n cwd: shell.harnessDir,\n env: serverEnv,\n });\n\n const data = {\n ...scaffold,\n server: { started: true, command, url, port, host, exposed, exitCode },\n };\n\n if (exitCode === 0) {\n return ctx.succeed(data, `dev: harness stopped cleanly.`);\n }\n\n return ctx.failWith({\n code: 'dev_server_failed',\n message: `\"${command}\" exited with code ${exitCode}.`,\n hint:\n 'Read the Next output on stderr. If it cannot resolve @ekanos/harness ' +\n `or next, reinstall dependencies in ${loaded.projectDir}; if a route ` +\n `fails to compile, delete ${shell.harnessDir} and re-run \"ekanos dev\".`,\n // Transparent passthrough of Next's own exit code.\n exitCode,\n data,\n });\n}\n\nfunction spawnServer(params: {\n ctx: CliContext;\n bin: string;\n argv: string[];\n cwd: string;\n env: Record<string, string>;\n}): Promise<number> {\n return new Promise<number>((resolve, reject) => {\n const child = spawn(params.bin, params.argv, {\n cwd: params.cwd,\n env: { ...process.env, ...params.env },\n // stdin inherited so Next's own key handling works; stdout/stderr piped\n // so we can route them and keep OUR stdout reserved for the envelope.\n stdio: ['inherit', 'pipe', 'pipe'],\n });\n\n child.stdout?.on('data', (chunk: Buffer) => {\n (params.ctx.jsonMode ? process.stderr : process.stdout).write(chunk);\n });\n child.stderr?.on('data', (chunk: Buffer) => {\n process.stderr.write(chunk);\n });\n\n child.on('error', (error) => reject(error));\n child.on('close', (code) => resolve(code ?? 1));\n });\n}\n\n/**\n * The hostname to show a human. `127.0.0.1` is spelled `localhost` because\n * that is what people type; a wildcard bind is spelled `localhost` too, but\n * only after `exposed` has already been warned about — printing `0.0.0.0` as\n * a URL is not useful.\n */\nfunction displayHost(host: string): string {\n if (host === '127.0.0.1' || host === '0.0.0.0' || host === '::') {\n return 'localhost';\n }\n if (host === '::1') return '[::1]';\n return host;\n}\n\nfunction parseHost(raw: string | undefined): string {\n if (raw === undefined) return DEFAULT_HOST;\n\n const host = raw.trim();\n if (host.length === 0) {\n throw usageError(\n 'Invalid --host \"\".',\n `Pass an interface to bind, e.g. \"--host 0.0.0.0\" to expose the ` +\n `harness on your network, or omit it to bind ${DEFAULT_HOST}.`,\n );\n }\n return host;\n}\n\nfunction parsePort(raw: string | undefined): number {\n if (raw === undefined) return DEFAULT_PORT;\n\n const port = Number(raw);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n throw usageError(\n `Invalid --port \"${raw}\".`,\n 'Pass an integer between 1 and 65535, e.g. \"--port 3100\".',\n );\n }\n return port;\n}\n"]}
@@ -31,7 +31,18 @@
31
31
  * shell must contain. Editing a comment in a template does not count — that is
32
32
  * what the template fingerprint in the marker is for.
33
33
  */
34
- export declare const SHELL_CONTRACT = 1;
34
+ export declare const SHELL_CONTRACT = 2;
35
+ /**
36
+ * Contract history — what each bump changed, so a mismatch message can be
37
+ * read against it:
38
+ *
39
+ * 1 the original ten-file shell: pages re-exporting `/routes`, the client
40
+ * shell + index page injecting the registry into `/app`.
41
+ * 2 adds `app/api/integrations/[slug]/[...path]/route.ts`, injecting the
42
+ * registry into the new `@ekanos/harness/api` server entry. A harness
43
+ * without that export cannot back the route; a shell without the route
44
+ * leaves that harness's live-mode OAuth loop unreachable.
45
+ */
35
46
  /**
36
47
  * The contract an installed `@ekanos/harness` declares it needs, from
37
48
  * `{ "ekanos": { "shellContract": N } }` in its package.json.
@@ -36,7 +36,18 @@ import { readTemplateSource, templatesDir } from './templates.js';
36
36
  * shell must contain. Editing a comment in a template does not count — that is
37
37
  * what the template fingerprint in the marker is for.
38
38
  */
39
- export const SHELL_CONTRACT = 1;
39
+ export const SHELL_CONTRACT = 2;
40
+ /**
41
+ * Contract history — what each bump changed, so a mismatch message can be
42
+ * read against it:
43
+ *
44
+ * 1 the original ten-file shell: pages re-exporting `/routes`, the client
45
+ * shell + index page injecting the registry into `/app`.
46
+ * 2 adds `app/api/integrations/[slug]/[...path]/route.ts`, injecting the
47
+ * registry into the new `@ekanos/harness/api` server entry. A harness
48
+ * without that export cannot back the route; a shell without the route
49
+ * leaves that harness's live-mode OAuth loop unreachable.
50
+ */
40
51
  /**
41
52
  * The contract an installed `@ekanos/harness` declares it needs, from
42
53
  * `{ "ekanos": { "shellContract": N } }` in its package.json.
@@ -1 +1 @@
1
- {"version":3,"file":"compatibility.js","sourceRoot":"","sources":["../src/compatibility.ts"],"names":[],"mappings":"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","sourcesContent":["import { createHash } from 'node:crypto';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\n\nimport { preconditionError } from './errors';\nimport { readTemplateSource, templatesDir } from './templates';\n\n/**\n * The shell templates ship with `@ekanos/cli`; the code they import ships with\n * `@ekanos/harness`. The two version independently, so a partner can upgrade\n * one without the other — and the interesting failure is the quiet one:\n *\n * `pnpm up @ekanos/harness` → 0.2, which moves an export.\n * `ekanos dev` regenerates from the OLD CLI's templates and stamps \"0.2\"\n * into the marker. Every later run now considers the shell current. It is\n * not: it was generated against an obsolete contract, and what the partner\n * sees is a module-resolution error rather than \"these are incompatible\".\n *\n * That directly attacks the promise the package boundary exists to keep — ship\n * a harness fix as a version bump a partner picks up with `pnpm up`. So the\n * CLI declares which `@ekanos/harness` versions ITS templates are written\n * against, checks the installed one before generating, and names the\n * incompatibility instead of letting a resolution error stand in for it.\n */\n\n/**\n * The shell contract this CLI's templates GENERATE.\n *\n * An integer, not a semver range. A range can only INFER compatibility from\n * version numbers — it cannot tell a harness bugfix from a change to the shell\n * the harness requires, so it either blocks upgrades that were fine or waves\n * through ones that were not. The integer is declared by both sides and is\n * authoritative.\n *\n * **Bump this only when the generated shell's coupling to the package\n * changes**: an export renamed or moved between `/app` and `/routes`, the\n * props of `RootLayout` or `IndexPage`, the server/client split, the alias\n * specifier, which side owns `@import 'tailwindcss'`, or the set of files the\n * shell must contain. Editing a comment in a template does not count — that is\n * what the template fingerprint in the marker is for.\n */\nexport const SHELL_CONTRACT = 1;\n\n/**\n * The contract an installed `@ekanos/harness` declares it needs, from\n * `{ \"ekanos\": { \"shellContract\": N } }` in its package.json.\n *\n * A package that declares nothing is read as contract 1 — the contract that\n * shipped first — so the harness released before this existed keeps working.\n * That default is deliberately silent, which is why `@ekanos/harness` asserts\n * the field's presence in its own pack test rather than relying on it.\n */\nexport function declaredShellContract(harnessManifest: unknown): number {\n if (typeof harnessManifest !== 'object' || harnessManifest === null) return 1;\n const ekanos = (harnessManifest as { ekanos?: unknown }).ekanos;\n if (typeof ekanos !== 'object' || ekanos === null) return 1;\n const declared = (ekanos as { shellContract?: unknown }).shellContract;\n return Number.isInteger(declared) ? (declared as number) : 1;\n}\n\n/** `[major, minor, patch]`, with anything non-numeric treated as 0. */\nfunction parseVersion(version: string): [number, number, number] {\n const core = version.split('+')[0]!.split('-')[0]!;\n const parts = core.split('.').map((part) => Number.parseInt(part, 10));\n return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];\n}\n\nexport function compareVersions(a: string, b: string): number {\n const left = parseVersion(a);\n const right = parseVersion(b);\n for (let i = 0; i < 3; i++) {\n if (left[i]! !== right[i]!) return left[i]! < right[i]! ? -1 : 1;\n }\n return 0;\n}\n\n/** This CLI's own version, read from its package.json at runtime. */\nexport function cliVersion(): string {\n const manifestPath = path.join(templatesDir(), '..', 'package.json');\n const parsed: unknown = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));\n const version =\n typeof parsed === 'object' && parsed !== null\n ? (parsed as { version?: unknown }).version\n : undefined;\n return typeof version === 'string' ? version : '0.0.0';\n}\n\n/**\n * Fail unless this CLI's templates generate the shell the installed harness\n * expects.\n *\n * The templates ship with `@ekanos/cli`; the code they import ships with\n * `@ekanos/harness`. The two version independently, so a partner can upgrade\n * one without the other — and the failure that matters is the quiet one:\n * `pnpm up @ekanos/harness` moves an export, `ekanos dev` regenerates from the\n * OLD CLI's templates, and the partner sees a module-resolution error rather\n * than anything naming the incompatibility.\n *\n * Byte-comparing generated files cannot catch it: the old templates' bytes are\n * exactly what the old CLI intends, so nothing regenerates differently and the\n * shell stays wired to something that moved. Only a declared contract can.\n *\n * Runs before anything is written, so an incompatible pair produces no shell\n * at all — a broken shell that builds is worse than no shell.\n */\nexport function assertHarnessCompatible(params: {\n harnessVersion: string;\n harnessManifest: unknown;\n installHint: (packageSpec: string) => string;\n}): void {\n const required = declaredShellContract(params.harnessManifest);\n if (required === SHELL_CONTRACT) return;\n\n if (required > SHELL_CONTRACT) {\n throw preconditionError(\n `@ekanos/cli ${cliVersion()} is too old for @ekanos/harness ` +\n `${params.harnessVersion}: that harness needs shell contract ` +\n `${required}, and this CLI generates ${SHELL_CONTRACT}.`,\n `Upgrade the CLI: ${params.installHint('@ekanos/cli@latest')}. Then ` +\n 're-run \"ekanos dev\" and the shell is regenerated for you.',\n );\n }\n\n throw preconditionError(\n `@ekanos/harness ${params.harnessVersion} is too old for @ekanos/cli ` +\n `${cliVersion()}: this CLI generates shell contract ${SHELL_CONTRACT}, ` +\n `and that harness needs ${required}.`,\n `Upgrade it: ${params.installHint('@ekanos/harness@latest')}.`,\n );\n}\n\n/**\n * A fingerprint of the template SOURCES, independent of the slug they are\n * rendered with.\n *\n * The harness version alone cannot certify a shell: the templates can change\n * while it stays put (a CLI patch release), and it can change while they stay\n * put. Hashing the sources means any edit to what we generate invalidates\n * every shell generated before it, including one produced by a CLI version\n * that never shipped.\n */\nexport function templatesFingerprint(templateNames: readonly string[]): string {\n const hash = createHash('sha256');\n for (const name of [...templateNames].sort()) {\n hash.update(name);\n hash.update('\\0');\n hash.update(readTemplateSource(name));\n hash.update('\\0');\n }\n return hash.digest('hex').slice(0, 16);\n}\n"]}
1
+ {"version":3,"file":"compatibility.js","sourceRoot":"","sources":["../src/compatibility.ts"],"names":[],"mappings":"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","sourcesContent":["import { createHash } from 'node:crypto';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\n\nimport { preconditionError } from './errors';\nimport { readTemplateSource, templatesDir } from './templates';\n\n/**\n * The shell templates ship with `@ekanos/cli`; the code they import ships with\n * `@ekanos/harness`. The two version independently, so a partner can upgrade\n * one without the other — and the interesting failure is the quiet one:\n *\n * `pnpm up @ekanos/harness` → 0.2, which moves an export.\n * `ekanos dev` regenerates from the OLD CLI's templates and stamps \"0.2\"\n * into the marker. Every later run now considers the shell current. It is\n * not: it was generated against an obsolete contract, and what the partner\n * sees is a module-resolution error rather than \"these are incompatible\".\n *\n * That directly attacks the promise the package boundary exists to keep — ship\n * a harness fix as a version bump a partner picks up with `pnpm up`. So the\n * CLI declares which `@ekanos/harness` versions ITS templates are written\n * against, checks the installed one before generating, and names the\n * incompatibility instead of letting a resolution error stand in for it.\n */\n\n/**\n * The shell contract this CLI's templates GENERATE.\n *\n * An integer, not a semver range. A range can only INFER compatibility from\n * version numbers — it cannot tell a harness bugfix from a change to the shell\n * the harness requires, so it either blocks upgrades that were fine or waves\n * through ones that were not. The integer is declared by both sides and is\n * authoritative.\n *\n * **Bump this only when the generated shell's coupling to the package\n * changes**: an export renamed or moved between `/app` and `/routes`, the\n * props of `RootLayout` or `IndexPage`, the server/client split, the alias\n * specifier, which side owns `@import 'tailwindcss'`, or the set of files the\n * shell must contain. Editing a comment in a template does not count — that is\n * what the template fingerprint in the marker is for.\n */\nexport const SHELL_CONTRACT = 2;\n\n/**\n * Contract history — what each bump changed, so a mismatch message can be\n * read against it:\n *\n * 1 the original ten-file shell: pages re-exporting `/routes`, the client\n * shell + index page injecting the registry into `/app`.\n * 2 adds `app/api/integrations/[slug]/[...path]/route.ts`, injecting the\n * registry into the new `@ekanos/harness/api` server entry. A harness\n * without that export cannot back the route; a shell without the route\n * leaves that harness's live-mode OAuth loop unreachable.\n */\n\n/**\n * The contract an installed `@ekanos/harness` declares it needs, from\n * `{ \"ekanos\": { \"shellContract\": N } }` in its package.json.\n *\n * A package that declares nothing is read as contract 1 — the contract that\n * shipped first — so the harness released before this existed keeps working.\n * That default is deliberately silent, which is why `@ekanos/harness` asserts\n * the field's presence in its own pack test rather than relying on it.\n */\nexport function declaredShellContract(harnessManifest: unknown): number {\n if (typeof harnessManifest !== 'object' || harnessManifest === null) return 1;\n const ekanos = (harnessManifest as { ekanos?: unknown }).ekanos;\n if (typeof ekanos !== 'object' || ekanos === null) return 1;\n const declared = (ekanos as { shellContract?: unknown }).shellContract;\n return Number.isInteger(declared) ? (declared as number) : 1;\n}\n\n/** `[major, minor, patch]`, with anything non-numeric treated as 0. */\nfunction parseVersion(version: string): [number, number, number] {\n const core = version.split('+')[0]!.split('-')[0]!;\n const parts = core.split('.').map((part) => Number.parseInt(part, 10));\n return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];\n}\n\nexport function compareVersions(a: string, b: string): number {\n const left = parseVersion(a);\n const right = parseVersion(b);\n for (let i = 0; i < 3; i++) {\n if (left[i]! !== right[i]!) return left[i]! < right[i]! ? -1 : 1;\n }\n return 0;\n}\n\n/** This CLI's own version, read from its package.json at runtime. */\nexport function cliVersion(): string {\n const manifestPath = path.join(templatesDir(), '..', 'package.json');\n const parsed: unknown = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));\n const version =\n typeof parsed === 'object' && parsed !== null\n ? (parsed as { version?: unknown }).version\n : undefined;\n return typeof version === 'string' ? version : '0.0.0';\n}\n\n/**\n * Fail unless this CLI's templates generate the shell the installed harness\n * expects.\n *\n * The templates ship with `@ekanos/cli`; the code they import ships with\n * `@ekanos/harness`. The two version independently, so a partner can upgrade\n * one without the other — and the failure that matters is the quiet one:\n * `pnpm up @ekanos/harness` moves an export, `ekanos dev` regenerates from the\n * OLD CLI's templates, and the partner sees a module-resolution error rather\n * than anything naming the incompatibility.\n *\n * Byte-comparing generated files cannot catch it: the old templates' bytes are\n * exactly what the old CLI intends, so nothing regenerates differently and the\n * shell stays wired to something that moved. Only a declared contract can.\n *\n * Runs before anything is written, so an incompatible pair produces no shell\n * at all — a broken shell that builds is worse than no shell.\n */\nexport function assertHarnessCompatible(params: {\n harnessVersion: string;\n harnessManifest: unknown;\n installHint: (packageSpec: string) => string;\n}): void {\n const required = declaredShellContract(params.harnessManifest);\n if (required === SHELL_CONTRACT) return;\n\n if (required > SHELL_CONTRACT) {\n throw preconditionError(\n `@ekanos/cli ${cliVersion()} is too old for @ekanos/harness ` +\n `${params.harnessVersion}: that harness needs shell contract ` +\n `${required}, and this CLI generates ${SHELL_CONTRACT}.`,\n `Upgrade the CLI: ${params.installHint('@ekanos/cli@latest')}. Then ` +\n 're-run \"ekanos dev\" and the shell is regenerated for you.',\n );\n }\n\n throw preconditionError(\n `@ekanos/harness ${params.harnessVersion} is too old for @ekanos/cli ` +\n `${cliVersion()}: this CLI generates shell contract ${SHELL_CONTRACT}, ` +\n `and that harness needs ${required}.`,\n `Upgrade it: ${params.installHint('@ekanos/harness@latest')}.`,\n );\n}\n\n/**\n * A fingerprint of the template SOURCES, independent of the slug they are\n * rendered with.\n *\n * The harness version alone cannot certify a shell: the templates can change\n * while it stays put (a CLI patch release), and it can change while they stay\n * put. Hashing the sources means any edit to what we generate invalidates\n * every shell generated before it, including one produced by a CLI version\n * that never shipped.\n */\nexport function templatesFingerprint(templateNames: readonly string[]): string {\n const hash = createHash('sha256');\n for (const name of [...templateNames].sort()) {\n hash.update(name);\n hash.update('\\0');\n hash.update(readTemplateSource(name));\n hash.update('\\0');\n }\n return hash.digest('hex').slice(0, 16);\n}\n"]}
@@ -15,11 +15,16 @@ export declare const VERSION_MARKER = ".ekanos-version";
15
15
  export declare const HARNESS_CONFIG_FILENAME = "harness.config.ts";
16
16
  /**
17
17
  * Every file in the generated shell: bundled template → path inside
18
- * `.ekanos/harness/`. The nine route files are mechanically derivable from the
18
+ * `.ekanos/harness/`. The ten route files are mechanically derivable from the
19
19
  * export map alone — which is the test of whether the package boundary is in
20
20
  * the right place. If a shell file ever needs a judgment call, the boundary
21
21
  * has moved, not the template.
22
22
  *
23
+ * `app/api/integrations/[slug]/[...path]/route.ts` is the shell's one SERVER
24
+ * route: it injects the registry into `@ekanos/harness/api`, which serves the
25
+ * live-mode OAuth loop, storage bridge and trigger route over a server-held
26
+ * context. It is the third registry injection point, beside the two pages.
27
+ *
23
28
  * `app/harness-shell.tsx` exists because a `HarnessIntegration` holds React
24
29
  * component references and MCP `run` functions. Next cannot serialize those
25
30
  * across a server→client boundary, so the `'use client'` mark has to sit ABOVE
@@ -21,11 +21,16 @@ export const VERSION_MARKER = '.ekanos-version';
21
21
  export const HARNESS_CONFIG_FILENAME = 'harness.config.ts';
22
22
  /**
23
23
  * Every file in the generated shell: bundled template → path inside
24
- * `.ekanos/harness/`. The nine route files are mechanically derivable from the
24
+ * `.ekanos/harness/`. The ten route files are mechanically derivable from the
25
25
  * export map alone — which is the test of whether the package boundary is in
26
26
  * the right place. If a shell file ever needs a judgment call, the boundary
27
27
  * has moved, not the template.
28
28
  *
29
+ * `app/api/integrations/[slug]/[...path]/route.ts` is the shell's one SERVER
30
+ * route: it injects the registry into `@ekanos/harness/api`, which serves the
31
+ * live-mode OAuth loop, storage bridge and trigger route over a server-held
32
+ * context. It is the third registry injection point, beside the two pages.
33
+ *
29
34
  * `app/harness-shell.tsx` exists because a `HarnessIntegration` holds React
30
35
  * component references and MCP `run` functions. Next cannot serialize those
31
36
  * across a server→client boundary, so the `'use client'` mark has to sit ABOVE
@@ -64,6 +69,10 @@ export const SHELL_FILES = [
64
69
  template: 'harness/app/[slug]/triggers/page.tsx.tmpl',
65
70
  dest: 'app/[slug]/triggers/page.tsx',
66
71
  },
72
+ {
73
+ template: 'harness/app/api/integrations/[slug]/[...path]/route.ts.tmpl',
74
+ dest: 'app/api/integrations/[slug]/[...path]/route.ts',
75
+ },
67
76
  { template: 'harness/next.config.mjs.tmpl', dest: 'next.config.mjs' },
68
77
  { template: 'harness/postcss.config.mjs.tmpl', dest: 'postcss.config.mjs' },
69
78
  { template: 'harness/tsconfig.json.tmpl', dest: 'tsconfig.json' },
@@ -1 +1 @@
1
- {"version":3,"file":"harness-scaffold.js","sourceRoot":"","sources":["../src/harness-scaffold.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'node:fs';\nimport * as path from 'node:path';\n\nimport { cliVersion, templatesFingerprint } from './compatibility';\nimport { invalidStateError, preconditionError } from './errors';\nimport {\n appendFileNoFollow,\n classifyPath,\n readFileNoFollow,\n writeFileNoFollow,\n} from './safe-fs';\nimport { readTemplate } from './templates';\n\n/** The dependency that owns every line of harness behaviour. */\nexport const HARNESS_PACKAGE = '@ekanos/harness';\n\n/** Where the generated shell lives, relative to the project root. */\nexport const HARNESS_DIR = path.join('.ekanos', 'harness');\n\n/** The single `.gitignore` line that covers the whole generated tree. */\nexport const GITIGNORE_ENTRY = '.ekanos/';\n\n/**\n * The staleness marker, inside the generated tree so deleting the tree also\n * deletes the marker. Holds the `@ekanos/harness` version that generated the\n * shell — a version bump means the shell may be wired against an older export\n * surface, so it is regenerated.\n */\nexport const VERSION_MARKER = '.ekanos-version';\n\n/** The partner-owned registry, at the PROJECT ROOT. Never regenerated. */\nexport const HARNESS_CONFIG_FILENAME = 'harness.config.ts';\n\n/**\n * Every file in the generated shell: bundled template → path inside\n * `.ekanos/harness/`. The nine route files are mechanically derivable from the\n * export map alone — which is the test of whether the package boundary is in\n * the right place. If a shell file ever needs a judgment call, the boundary\n * has moved, not the template.\n *\n * `app/harness-shell.tsx` exists because a `HarnessIntegration` holds React\n * component references and MCP `run` functions. Next cannot serialize those\n * across a server→client boundary, so the `'use client'` mark has to sit ABOVE\n * the point where the registry is injected — hence a client shell of its own\n * rather than a client `RootLayout` called from a server layout. Keeping\n * `app/page.tsx` a server component is what lets `/` prerender static.\n */\nexport const SHELL_FILES: readonly { template: string; dest: string }[] = [\n { template: 'harness/app/layout.tsx.tmpl', dest: 'app/layout.tsx' },\n {\n template: 'harness/app/harness-shell.tsx.tmpl',\n dest: 'app/harness-shell.tsx',\n },\n { template: 'harness/app/page.tsx.tmpl', dest: 'app/page.tsx' },\n {\n template: 'harness/app/[slug]/layout.tsx.tmpl',\n dest: 'app/[slug]/layout.tsx',\n },\n {\n template: 'harness/app/[slug]/widgets/page.tsx.tmpl',\n dest: 'app/[slug]/widgets/page.tsx',\n },\n {\n template: 'harness/app/[slug]/widgets/[widgetId]/page.tsx.tmpl',\n dest: 'app/[slug]/widgets/[widgetId]/page.tsx',\n },\n {\n template: 'harness/app/[slug]/tile/page.tsx.tmpl',\n dest: 'app/[slug]/tile/page.tsx',\n },\n {\n template: 'harness/app/[slug]/activation/page.tsx.tmpl',\n dest: 'app/[slug]/activation/page.tsx',\n },\n {\n template: 'harness/app/[slug]/triggers/page.tsx.tmpl',\n dest: 'app/[slug]/triggers/page.tsx',\n },\n { template: 'harness/next.config.mjs.tmpl', dest: 'next.config.mjs' },\n { template: 'harness/postcss.config.mjs.tmpl', dest: 'postcss.config.mjs' },\n { template: 'harness/tsconfig.json.tmpl', dest: 'tsconfig.json' },\n { template: 'harness/styles/globals.css.tmpl', dest: 'styles/globals.css' },\n { template: 'harness/package.json.tmpl', dest: 'package.json' },\n {\n template: 'harness/types/team-accounts.d.ts.tmpl',\n dest: 'types/team-accounts.d.ts',\n },\n];\n\n/**\n * Packages the shell needs to resolve upward out of the partner's own\n * `node_modules`. The shell declares no dependencies of its own by design, so\n * a missing one shows up as an opaque Next resolution error at dev-server\n * start; checking here turns that into one precondition with one fix.\n */\nexport const REQUIRED_DEPENDENCIES = [\n HARNESS_PACKAGE,\n 'next',\n 'react',\n 'react-dom',\n 'tailwindcss',\n '@tailwindcss/postcss',\n] as const;\n\nexport interface WrittenFiles {\n created: string[];\n updated: string[];\n unchanged: string[];\n}\n\n/**\n * Read an installed package's `package.json` out of a project's TOP-LEVEL\n * `node_modules`. Resolved by direct path join rather than `require.resolve`\n * on purpose, for two independent reasons:\n *\n * - The CLI runs from its own install and must read the version of the copy\n * in THE CALLER'S project, not one hoisted next to itself.\n * - Several of the packages this reads (`@ekanos/integration-schema`\n * included) declare an `exports` map with no `./package.json` subpath, so\n * `require.resolve('<pkg>/package.json')` throws\n * `ERR_PACKAGE_PATH_NOT_EXPORTED` under Node's ESM resolver. A plain file\n * read is not subject to the export map at all.\n *\n * Shared by every \"what does this project have installed?\" check in the\n * CLI — `readInstalledHarness` below, the schema-skew gate, and `upgrade`'s\n * current-toolchain report — so there is exactly one implementation of \"read\n * a package's on-disk version\", not a subtly different one per call site.\n */\nexport interface InstalledPackage {\n version: string;\n /** The raw package.json, for callers that need more than the version. */\n manifest: unknown;\n}\n\nexport function readInstalledPackageManifest(\n projectDir: string,\n packageName: string,\n): InstalledPackage | null {\n const manifestPath = path.join(\n projectDir,\n 'node_modules',\n ...packageName.split('/'),\n 'package.json',\n );\n if (!fs.existsSync(manifestPath)) return null;\n\n try {\n const parsed: unknown = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));\n const version =\n typeof parsed === 'object' && parsed !== null\n ? (parsed as { version?: unknown }).version\n : undefined;\n if (typeof version !== 'string' || version.length === 0) return null;\n return { version, manifest: parsed };\n } catch (error) {\n throw preconditionError(\n `${packageName}'s package.json in node_modules is unreadable: ${\n error instanceof Error ? error.message : String(error)\n }`,\n `Reinstall dependencies in ${projectDir} so ${packageName} resolves.`,\n );\n }\n}\n\n/** Read the installed `@ekanos/harness` version out of the partner's project. */\nexport type InstalledHarness = InstalledPackage;\n\nexport function readInstalledHarness(\n projectDir: string,\n): InstalledHarness | null {\n return readInstalledPackageManifest(projectDir, HARNESS_PACKAGE);\n}\n\n/** Just the installed version, for callers that need nothing else. */\nexport function readInstalledHarnessVersion(projectDir: string): string | null {\n return readInstalledHarness(projectDir)?.version ?? null;\n}\n\n/** Which of `REQUIRED_DEPENDENCIES` are not installed in the project root. */\nexport function missingDependencies(projectDir: string): string[] {\n return REQUIRED_DEPENDENCIES.filter(\n (name) =>\n !fs.existsSync(\n path.join(projectDir, 'node_modules', name, 'package.json'),\n ),\n );\n}\n\n/**\n * What generated an existing shell.\n *\n * All three fields matter, and recording only `harness` was a real hole: the\n * templates ship with the CLI, so a shell can be stale because the CLI changed\n * while the harness did not. `templates` is the authoritative one — it is a\n * hash of the template sources, so it moves whenever what we generate moves,\n * including for a CLI build that never shipped a version bump.\n */\nexport interface ShellMarker {\n harness: string;\n /** null for a legacy bare-version marker written before this was recorded. */\n cli: string | null;\n templates: string | null;\n}\n\n/**\n * Three outcomes, not two, because \"no marker\" and \"unreadable marker\" call\n * for opposite responses.\n *\n * A marker's PRESENCE is itself evidence the tree is ours — a squatter does\n * not write `.ekanos-version` — and the likeliest cause of an unreadable one\n * is our own interrupted write. Everything under `.ekanos/harness/` is\n * CLI-owned, gitignored and reproducible from the templates, so regenerating\n * destroys nothing: there is no value at risk to justify charging a partner a\n * confusing `--force` to recover from our own bad write.\n *\n * An ABSENT marker over a non-empty tree is the opposite: nothing says the\n * files are ours, so we refuse. That is the L1 rule and it stays.\n */\nexport type MarkerState =\n | { kind: 'missing' }\n | { kind: 'corrupt' }\n | { kind: 'ok'; marker: ShellMarker };\n\nexport function readShellMarkerState(harnessDir: string): MarkerState {\n const marker = path.join(harnessDir, VERSION_MARKER);\n if (classifyPath(marker) !== 'file') return { kind: 'missing' };\n\n const raw = (readFileNoFollow(harnessDir, marker) ?? '').trim();\n if (raw.length === 0) return { kind: 'corrupt' };\n\n const parsed = parseMarker(raw);\n return parsed === null ? { kind: 'corrupt' } : { kind: 'ok', marker: parsed };\n}\n\nfunction parseMarker(raw: string): ShellMarker | null {\n // v1 markers were a bare version string. Read them rather than crashing, and\n // report the unknown fields as null so the shell counts as stale and is\n // regenerated once — which is the correct outcome for an upgrade.\n //\n // It must actually LOOK like a version: without this, any garbage that does\n // not happen to start with `{` — a truncated write, a stray word — reads as\n // a legacy marker named after the garbage, and the corrupt-marker path never\n // fires. That is how a truncated `.ekanos-version` was silently reported as\n // \"@ekanos/harness truncat → 0.1.0\" instead of \"unreadable\".\n if (!raw.startsWith('{')) {\n return /^\\d+\\.\\d+/.test(raw)\n ? { harness: raw, cli: null, templates: null }\n : null;\n }\n\n try {\n const parsed: unknown = JSON.parse(raw);\n if (typeof parsed !== 'object' || parsed === null) return null;\n const record = parsed as Record<string, unknown>;\n const harness = record.harness;\n if (typeof harness !== 'string') return null;\n return {\n harness,\n cli: typeof record.cli === 'string' ? record.cli : null,\n templates: typeof record.templates === 'string' ? record.templates : null,\n };\n } catch {\n return null;\n }\n}\n\n/** The parsed marker, or null when it is absent OR unreadable. */\nexport function readShellMarker(harnessDir: string): ShellMarker | null {\n const state = readShellMarkerState(harnessDir);\n return state.kind === 'ok' ? state.marker : null;\n}\n\n/** The `@ekanos/harness` version an existing shell was generated against. */\nexport function readShellVersion(harnessDir: string): string | null {\n return readShellMarker(harnessDir)?.harness ?? null;\n}\n\nfunction serializeMarker(marker: ShellMarker): string {\n return `${JSON.stringify(marker, null, 2)}\\n`;\n}\n\n/**\n * Write a generated file, but ONLY when its bytes would change.\n *\n * This is what makes a re-run cheap and a watcher quiet: an unchanged file is\n * not rewritten, so its mtime does not move and Next's dev server does not\n * rebuild. It also means a hand-edit inside the CLI-owned tree is silently\n * corrected on the next run, which is the intended contract for `.ekanos/`.\n */\nexport function writeIfChanged(\n root: string,\n destPath: string,\n contents: string,\n into: WrittenFiles,\n label: string,\n force: boolean,\n): void {\n // `classifyPath` uses lstat, so a symlink here is reported as a symlink\n // rather than as whatever it points at — including a DANGLING one, which\n // `existsSync` would call absent and a plain write would then materialize\n // outside the project.\n const kind = classifyPath(destPath);\n const exists = kind !== 'missing';\n\n if (\n !force &&\n kind === 'file' &&\n readFileNoFollow(root, destPath) === contents\n ) {\n into.unchanged.push(label);\n return;\n }\n\n writeFileNoFollow(root, destPath, contents);\n (exists ? into.updated : into.created).push(label);\n}\n\nexport interface ScaffoldShellArgs {\n projectDir: string;\n slug: string;\n harnessVersion: string;\n /** Rewrite every file even when its bytes already match. */\n force: boolean;\n /** Extra Tailwind @source globs from ekanos.json, already validated. */\n sourceGlobs?: readonly string[];\n}\n\nexport interface ScaffoldShellResult extends WrittenFiles {\n harnessDir: string;\n /** The version the shell carried before this run — null on a first run. */\n previousVersion: string | null;\n /** What generated the previous shell, or null on a first run. */\n previousMarker: ShellMarker | null;\n /** True when anything that generated the shell has since changed. */\n stale: boolean;\n /**\n * Which of harness / cli / templates moved. Empty on a first run; this is\n * what lets `dev` say WHY it regenerated rather than just that it did.\n */\n staleReasons: string[];\n}\n\n/**\n * Generate `.ekanos/harness/**` for `harnessVersion`. Every file here is\n * CLI-owned and gitignored, so regeneration needs no permission and asks no\n * question — the partner owns `harness.config.ts` and their integration\n * source, and nothing else.\n */\nexport function scaffoldShell(args: ScaffoldShellArgs): ScaffoldShellResult {\n const harnessDir = path.join(args.projectDir, HARNESS_DIR);\n assertShellDirIsOurs(harnessDir, args.force);\n\n const markerState = readShellMarkerState(harnessDir);\n const previousMarker = markerState.kind === 'ok' ? markerState.marker : null;\n const previousVersion = previousMarker?.harness ?? null;\n const marker: ShellMarker = {\n harness: args.harnessVersion,\n cli: cliVersion(),\n templates: templatesFingerprint(SHELL_FILES.map((f) => f.template)),\n };\n\n // A shell is current only if EVERYTHING that produced it is unchanged.\n // Checking the harness version alone certified shells generated by an older\n // CLI, against templates that had since moved, as up to date.\n const staleReasons: string[] = [];\n if (markerState.kind === 'corrupt') {\n staleReasons.push(\n `${VERSION_MARKER} was unreadable, so the existing shell could not be ` +\n 'certified',\n );\n }\n if (previousMarker !== null) {\n if (previousMarker.harness !== marker.harness) {\n staleReasons.push(\n `@ekanos/harness ${previousMarker.harness} → ${marker.harness}`,\n );\n }\n if (previousMarker.cli !== marker.cli) {\n staleReasons.push(\n `@ekanos/cli ${previousMarker.cli ?? 'unknown'} → ${marker.cli}`,\n );\n }\n if (previousMarker.templates !== marker.templates) {\n staleReasons.push('the shell templates changed');\n }\n }\n const stale = staleReasons.length > 0;\n\n const files: WrittenFiles = { created: [], updated: [], unchanged: [] };\n const vars = {\n SLUG: args.slug,\n SOURCE_GLOBS: renderSourceGlobs(args.sourceGlobs ?? []),\n };\n\n for (const { template, dest } of SHELL_FILES) {\n writeIfChanged(\n args.projectDir,\n path.join(harnessDir, dest),\n readTemplate(template, vars),\n files,\n dest,\n args.force,\n );\n }\n\n writeIfChanged(\n args.projectDir,\n path.join(harnessDir, VERSION_MARKER),\n serializeMarker(marker),\n files,\n VERSION_MARKER,\n args.force,\n );\n\n return {\n ...files,\n harnessDir,\n previousVersion,\n previousMarker,\n stale,\n staleReasons,\n };\n}\n\n/**\n * Turn validated `sourceGlobs` into the `@source` lines the generated\n * stylesheet carries, each relative to the shell's `styles/` directory.\n *\n * Tailwind never scans what it is not pointed at, and a glob that matches\n * nothing fails silently — so an integration living outside the default\n * layout renders completely unstyled with no error anywhere. That is what\n * `sourceGlobs` is for, and until now it was parsed and thrown away.\n */\nfunction renderSourceGlobs(globs: readonly string[]): string {\n if (globs.length === 0) return '';\n const lines = globs.map((glob) => `@source '${SHELL_TO_PROJECT}${glob}';`);\n return `\\n/* From \"sourceGlobs\" in ekanos.json. */\\n${lines.join('\\n')}\\n`;\n}\n\n/** From `.ekanos/harness/styles/` back up to the project root. */\nconst SHELL_TO_PROJECT = '../../../';\n\n/**\n * Establish that `.ekanos/harness` is a tree WE generated before writing a\n * single byte into it, and return the version marker it carried.\n *\n * Path location alone is not ownership. A directory that exists but carries no\n * `.ekanos-version` was put there by something else — a partner, another tool,\n * a symlink — and clobbering it because it happens to sit at the path we like\n * is the same class of mistake as following a symlink. An empty directory is\n * fine (that is just `mkdir -p` having run), and `--force` is the explicit\n * override.\n *\n * Runs BEFORE `bootstrapHarnessConfig` in `dev`, so a shell that cannot be\n * written leaves the project completely untouched rather than half-scaffolded.\n */\nexport function assertShellDirIsOurs(\n harnessDir: string,\n force: boolean,\n): string | null {\n const kind = classifyPath(harnessDir);\n\n if (kind === 'symlink') {\n throw invalidStateError(\n `${harnessDir} is a symlink, so \"ekanos dev\" will not write through it.`,\n `Delete ${harnessDir} and re-run \"ekanos dev\" — the shell is generated, ` +\n 'so nothing is lost.',\n );\n }\n\n if (kind !== 'missing' && kind !== 'directory') {\n throw invalidStateError(\n `${harnessDir} exists and is not a directory.`,\n `Move or delete ${harnessDir}, then re-run \"ekanos dev\".`,\n );\n }\n\n if (kind === 'missing') return null;\n\n const state = readShellMarkerState(harnessDir);\n\n // A marker we wrote, however mangled, still identifies the tree as ours.\n if (state.kind === 'ok') return state.marker.harness;\n if (state.kind === 'corrupt' || force) return null;\n\n if (fs.readdirSync(harnessDir).length === 0) return null;\n\n throw invalidStateError(\n `${harnessDir} already contains files but no ${VERSION_MARKER} marker, ` +\n 'so it was not generated by \"ekanos dev\".',\n `Delete ${harnessDir} if you do not need it, or pass --force to ` +\n 'overwrite it. Everything \"ekanos dev\" generates is reproducible; ' +\n 'anything else in there is not.',\n );\n}\n\n/**\n * The module specifier `harness.config.ts` uses to import the partner's\n * integration: the `entry` from ekanos.json, made relative and extension-free.\n * \"src/integration.ts\" → \"src/integration\".\n */\nexport function entryImportSpecifier(entry: string): string {\n const normalized = entry.split(path.sep).join('/').replace(/^\\.\\//, '');\n return normalized.replace(/\\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/, '');\n}\n\n/**\n * The same specifier as a JavaScript string LITERAL, ready to drop into a\n * generated `import` statement.\n *\n * `entry` is attacker-influenced data from `ekanos.json`, and a POSIX filename\n * may legally contain a quote or a newline. Interpolating it raw would let a\n * crafted-but-valid path close the string and append statements to the module\n * we generate. `JSON.stringify` produces a correctly escaped literal, which is\n * the actual fix; the explicit rejection below is a second line that also\n * gives the partner a comprehensible error instead of a bizarre filename\n * silently appearing, escaped, in their config.\n *\n * This does not cross a trust boundary on its own — a partner's own code runs\n * unsandboxed anyway — but \"the templates cannot be injected into\" is a claim\n * we make, and it should be true.\n */\nexport function entryImportLiteral(entry: string): string {\n const specifier = entryImportSpecifier(entry);\n\n // eslint-disable-next-line no-control-regex\n if (/[\"'`\\\\\\n\\r\\u0000-\\u001f]/.test(specifier)) {\n throw preconditionError(\n `The \"entry\" in ekanos.json contains characters that cannot appear in ` +\n `an import specifier: ${JSON.stringify(specifier)}.`,\n 'Rename the file so its path has no quotes, backslashes or control ' +\n 'characters, then update \"entry\" in ekanos.json to match.',\n );\n }\n\n return JSON.stringify(`./${specifier}`);\n}\n\nexport interface BootstrapConfigResult {\n /** True only when this run created the file. */\n bootstrapped: boolean;\n configPath: string;\n}\n\n/**\n * Write a starter `harness.config.ts` at the project root — ONCE.\n *\n * An existing file is left strictly alone, byte for byte, and no flag changes\n * that: it is the one harness file the partner owns, it is git-tracked, and\n * regenerating it would throw away their fixtures. `--force` regenerates the\n * shell, never this.\n */\nexport function bootstrapHarnessConfig(\n projectDir: string,\n integrations: readonly { slug: string; entry: string }[],\n): BootstrapConfigResult {\n const configPath = path.join(projectDir, HARNESS_CONFIG_FILENAME);\n const kind = classifyPath(configPath);\n\n // A symlink here — including a dangling one, which `existsSync` reports as\n // absent — would have us write through to its target. Since this file is the\n // partner's to own, the only safe answer is to touch nothing and say why.\n if (kind === 'symlink') {\n throw invalidStateError(\n `${configPath} is a symlink, so \"ekanos dev\" will not write through it.`,\n `Replace ${configPath} with a real file (or delete it to have one ` +\n 'generated), then re-run \"ekanos dev\".',\n );\n }\n\n if (kind !== 'missing') {\n return { bootstrapped: false, configPath };\n }\n\n writeFileNoFollow(projectDir, configPath, renderHarnessConfig(integrations));\n\n return { bootstrapped: true, configPath };\n}\n\n/**\n * Render the starter registry for EVERY integration the project declares.\n *\n * The harness registry is an array and `ekanos.json` can now hold a list, so a\n * project's second integration reaches the harness the same way its first\n * does. Each entry imports the definition under a distinct local identifier\n * derived from its slug, because two `integration` imports in one module do\n * not compile.\n */\nfunction renderHarnessConfig(\n integrations: readonly { slug: string; entry: string }[],\n): string {\n const imports: string[] = [];\n const entries: string[] = [];\n\n for (const integration of integrations) {\n const ident = identifierForSlug(integration.slug);\n imports.push(\n `import { integration as ${ident} } from ${entryImportLiteral(\n integration.entry,\n )};`,\n );\n entries.push(\n readTemplate('harness.config.entry.ts.tmpl', {\n IDENT: ident,\n SLUG: integration.slug,\n }),\n );\n }\n\n return readTemplate('harness.config.ts.tmpl', {\n INTEGRATION_IMPORTS: imports.join('\\n'),\n INTEGRATION_ENTRIES: entries.join(''),\n });\n}\n\n/**\n * \"acme-crm\" → \"acmeCrm\". A slug is kebab-case by schema, so this only has to\n * handle hyphens, and a leading digit is impossible.\n */\nexport function identifierForSlug(slug: string): string {\n const camel = slug.replace(/-([a-z0-9])/g, (_, char: string) =>\n char.toUpperCase(),\n );\n return /^[0-9]/.test(camel) ? `i${camel}` : camel;\n}\n\n/**\n * Add `.ekanos/` to the project `.gitignore`, exactly once.\n *\n * Matching is line-wise over the spellings git treats as the same rule\n * (`.ekanos`, `.ekanos/`, `/.ekanos/`), so re-running never appends a\n * duplicate — the thing that makes a scaffold verb safe to run in a loop.\n * A negation (`!.ekanos/`) is deliberately NOT treated as a match: the\n * partner meant to un-ignore it, and we do not fight that silently.\n */\nexport function ensureGitignoreEntry(projectDir: string): boolean {\n const gitignorePath = path.join(projectDir, '.gitignore');\n const block = `# Ekanos dev harness — generated by \"ekanos dev\", never edited by hand\\n${GITIGNORE_ENTRY}\\n`;\n\n const kind = classifyPath(gitignorePath);\n\n if (kind === 'symlink') {\n throw invalidStateError(\n `${gitignorePath} is a symlink, so \"ekanos dev\" will not append to it.`,\n `Add \"${GITIGNORE_ENTRY}\" to your real .gitignore yourself, or replace ` +\n `${gitignorePath} with a regular file, then re-run \"ekanos dev\".`,\n );\n }\n\n if (kind === 'missing') {\n writeFileNoFollow(projectDir, gitignorePath, block);\n return true;\n }\n\n const existing = readFileNoFollow(projectDir, gitignorePath) ?? '';\n if (gitignoreCovers(existing)) return false;\n\n const separator =\n existing.length === 0 || existing.endsWith('\\n') ? '' : '\\n';\n appendFileNoFollow(projectDir, gitignorePath, `${separator}\\n${block}`);\n return true;\n}\n\nfunction gitignoreCovers(source: string): boolean {\n const equivalents = new Set(['.ekanos', '.ekanos/', '/.ekanos', '/.ekanos/']);\n return source\n .split('\\n')\n .map((line) => line.trim())\n .some((line) => equivalents.has(line));\n}\n"]}
1
+ {"version":3,"file":"harness-scaffold.js","sourceRoot":"","sources":["../src/harness-scaffold.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,SAAS,CAAC;AAC9B,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAElC,OAAO,EAAE,UAAU,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AACnE,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAChE,OAAO,EACL,kBAAkB,EAClB,YAAY,EACZ,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,gEAAgE;AAChE,MAAM,CAAC,MAAM,eAAe,GAAG,iBAAiB,CAAC;AAEjD,qEAAqE;AACrE,MAAM,CAAC,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;AAE3D,yEAAyE;AACzE,MAAM,CAAC,MAAM,eAAe,GAAG,UAAU,CAAC;AAE1C;;;;;GAKG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,iBAAiB,CAAC;AAEhD,0EAA0E;AAC1E,MAAM,CAAC,MAAM,uBAAuB,GAAG,mBAAmB,CAAC;AAE3D;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,CAAC,MAAM,WAAW,GAAkD;IACxE,EAAE,QAAQ,EAAE,6BAA6B,EAAE,IAAI,EAAE,gBAAgB,EAAE;IACnE;QACE,QAAQ,EAAE,oCAAoC;QAC9C,IAAI,EAAE,uBAAuB;KAC9B;IACD,EAAE,QAAQ,EAAE,2BAA2B,EAAE,IAAI,EAAE,cAAc,EAAE;IAC/D;QACE,QAAQ,EAAE,oCAAoC;QAC9C,IAAI,EAAE,uBAAuB;KAC9B;IACD;QACE,QAAQ,EAAE,0CAA0C;QACpD,IAAI,EAAE,6BAA6B;KACpC;IACD;QACE,QAAQ,EAAE,qDAAqD;QAC/D,IAAI,EAAE,wCAAwC;KAC/C;IACD;QACE,QAAQ,EAAE,uCAAuC;QACjD,IAAI,EAAE,0BAA0B;KACjC;IACD;QACE,QAAQ,EAAE,6CAA6C;QACvD,IAAI,EAAE,gCAAgC;KACvC;IACD;QACE,QAAQ,EAAE,2CAA2C;QACrD,IAAI,EAAE,8BAA8B;KACrC;IACD;QACE,QAAQ,EAAE,6DAA6D;QACvE,IAAI,EAAE,gDAAgD;KACvD;IACD,EAAE,QAAQ,EAAE,8BAA8B,EAAE,IAAI,EAAE,iBAAiB,EAAE;IACrE,EAAE,QAAQ,EAAE,iCAAiC,EAAE,IAAI,EAAE,oBAAoB,EAAE;IAC3E,EAAE,QAAQ,EAAE,4BAA4B,EAAE,IAAI,EAAE,eAAe,EAAE;IACjE,EAAE,QAAQ,EAAE,iCAAiC,EAAE,IAAI,EAAE,oBAAoB,EAAE;IAC3E,EAAE,QAAQ,EAAE,2BAA2B,EAAE,IAAI,EAAE,cAAc,EAAE;IAC/D;QACE,QAAQ,EAAE,uCAAuC;QACjD,IAAI,EAAE,0BAA0B;KACjC;CACF,CAAC;AAEF;;;;;GAKG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG;IACnC,eAAe;IACf,MAAM;IACN,OAAO;IACP,WAAW;IACX,aAAa;IACb,sBAAsB;CACd,CAAC;AAgCX,MAAM,UAAU,4BAA4B,CAC1C,UAAkB,EAClB,WAAmB;IAEnB,MAAM,YAAY,GAAG,IAAI,CAAC,IAAI,CAC5B,UAAU,EACV,cAAc,EACd,GAAG,WAAW,CAAC,KAAK,CAAC,GAAG,CAAC,EACzB,cAAc,CACf,CAAC;IACF,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,YAAY,CAAC;QAAE,OAAO,IAAI,CAAC;IAE9C,IAAI,CAAC;QACH,MAAM,MAAM,GAAY,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,YAAY,EAAE,MAAM,CAAC,CAAC,CAAC;QAC1E,MAAM,OAAO,GACX,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,KAAK,IAAI;YAC3C,CAAC,CAAE,MAAgC,CAAC,OAAO;YAC3C,CAAC,CAAC,SAAS,CAAC;QAChB,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QACrE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;IACvC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,iBAAiB,CACrB,GAAG,WAAW,kDACZ,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CACvD,EAAE,EACF,6BAA6B,UAAU,OAAO,WAAW,YAAY,CACtE,CAAC;IACJ,CAAC;AACH,CAAC;AAKD,MAAM,UAAU,oBAAoB,CAClC,UAAkB;IAElB,OAAO,4BAA4B,CAAC,UAAU,EAAE,eAAe,CAAC,CAAC;AACnE,CAAC;AAED,sEAAsE;AACtE,MAAM,UAAU,2BAA2B,CAAC,UAAkB;;IAC5D,OAAO,MAAA,MAAA,oBAAoB,CAAC,UAAU,CAAC,0CAAE,OAAO,mCAAI,IAAI,CAAC;AAC3D,CAAC;AAED,8EAA8E;AAC9E,MAAM,UAAU,mBAAmB,CAAC,UAAkB;IACpD,OAAO,qBAAqB,CAAC,MAAM,CACjC,CAAC,IAAI,EAAE,EAAE,CACP,CAAC,EAAE,CAAC,UAAU,CACZ,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,cAAc,EAAE,IAAI,EAAE,cAAc,CAAC,CAC5D,CACJ,CAAC;AACJ,CAAC;AAqCD,MAAM,UAAU,oBAAoB,CAAC,UAAkB;;IACrD,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,cAAc,CAAC,CAAC;IACrD,IAAI,YAAY,CAAC,MAAM,CAAC,KAAK,MAAM;QAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;IAEhE,MAAM,GAAG,GAAG,CAAC,MAAA,gBAAgB,CAAC,UAAU,EAAE,MAAM,CAAC,mCAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IAChE,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;IAEjD,MAAM,MAAM,GAAG,WAAW,CAAC,GAAG,CAAC,CAAC;IAChC,OAAO,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC;AAChF,CAAC;AAED,SAAS,WAAW,CAAC,GAAW;IAC9B,6EAA6E;IAC7E,wEAAwE;IACxE,kEAAkE;IAClE,EAAE;IACF,4EAA4E;IAC5E,4EAA4E;IAC5E,6EAA6E;IAC7E,4EAA4E;IAC5E,6DAA6D;IAC7D,IAAI,CAAC,GAAG,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;QACzB,OAAO,WAAW,CAAC,IAAI,CAAC,GAAG,CAAC;YAC1B,CAAC,CAAC,EAAE,OAAO,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE;YAC9C,CAAC,CAAC,IAAI,CAAC;IACX,CAAC;IAED,IAAI,CAAC;QACH,MAAM,MAAM,GAAY,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QACxC,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC;QAC/D,MAAM,MAAM,GAAG,MAAiC,CAAC;QACjD,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC;QAC/B,IAAI,OAAO,OAAO,KAAK,QAAQ;YAAE,OAAO,IAAI,CAAC;QAC7C,OAAO;YACL,OAAO;YACP,GAAG,EAAE,OAAO,MAAM,CAAC,GAAG,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI;YACvD,SAAS,EAAE,OAAO,MAAM,CAAC,SAAS,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI;SAC1E,CAAC;IACJ,CAAC;IAAC,WAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,kEAAkE;AAClE,MAAM,UAAU,eAAe,CAAC,UAAkB;IAChD,MAAM,KAAK,GAAG,oBAAoB,CAAC,UAAU,CAAC,CAAC;IAC/C,OAAO,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC;AACnD,CAAC;AAED,6EAA6E;AAC7E,MAAM,UAAU,gBAAgB,CAAC,UAAkB;;IACjD,OAAO,MAAA,MAAA,eAAe,CAAC,UAAU,CAAC,0CAAE,OAAO,mCAAI,IAAI,CAAC;AACtD,CAAC;AAED,SAAS,eAAe,CAAC,MAAmB;IAC1C,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC;AAChD,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,cAAc,CAC5B,IAAY,EACZ,QAAgB,EAChB,QAAgB,EAChB,IAAkB,EAClB,KAAa,EACb,KAAc;IAEd,wEAAwE;IACxE,yEAAyE;IACzE,0EAA0E;IAC1E,uBAAuB;IACvB,MAAM,IAAI,GAAG,YAAY,CAAC,QAAQ,CAAC,CAAC;IACpC,MAAM,MAAM,GAAG,IAAI,KAAK,SAAS,CAAC;IAElC,IACE,CAAC,KAAK;QACN,IAAI,KAAK,MAAM;QACf,gBAAgB,CAAC,IAAI,EAAE,QAAQ,CAAC,KAAK,QAAQ,EAC7C,CAAC;QACD,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC3B,OAAO;IACT,CAAC;IAED,iBAAiB,CAAC,IAAI,EAAE,QAAQ,EAAE,QAAQ,CAAC,CAAC;IAC5C,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AACrD,CAAC;AA2BD;;;;;GAKG;AACH,MAAM,UAAU,aAAa,CAAC,IAAuB;;IACnD,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,WAAW,CAAC,CAAC;IAC3D,oBAAoB,CAAC,UAAU,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC;IAE7C,MAAM,WAAW,GAAG,oBAAoB,CAAC,UAAU,CAAC,CAAC;IACrD,MAAM,cAAc,GAAG,WAAW,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC;IAC7E,MAAM,eAAe,GAAG,MAAA,cAAc,aAAd,cAAc,uBAAd,cAAc,CAAE,OAAO,mCAAI,IAAI,CAAC;IACxD,MAAM,MAAM,GAAgB;QAC1B,OAAO,EAAE,IAAI,CAAC,cAAc;QAC5B,GAAG,EAAE,UAAU,EAAE;QACjB,SAAS,EAAE,oBAAoB,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;KACpE,CAAC;IAEF,uEAAuE;IACvE,4EAA4E;IAC5E,8DAA8D;IAC9D,MAAM,YAAY,GAAa,EAAE,CAAC;IAClC,IAAI,WAAW,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;QACnC,YAAY,CAAC,IAAI,CACf,GAAG,cAAc,sDAAsD;YACrE,WAAW,CACd,CAAC;IACJ,CAAC;IACD,IAAI,cAAc,KAAK,IAAI,EAAE,CAAC;QAC5B,IAAI,cAAc,CAAC,OAAO,KAAK,MAAM,CAAC,OAAO,EAAE,CAAC;YAC9C,YAAY,CAAC,IAAI,CACf,mBAAmB,cAAc,CAAC,OAAO,MAAM,MAAM,CAAC,OAAO,EAAE,CAChE,CAAC;QACJ,CAAC;QACD,IAAI,cAAc,CAAC,GAAG,KAAK,MAAM,CAAC,GAAG,EAAE,CAAC;YACtC,YAAY,CAAC,IAAI,CACf,eAAe,MAAA,cAAc,CAAC,GAAG,mCAAI,SAAS,MAAM,MAAM,CAAC,GAAG,EAAE,CACjE,CAAC;QACJ,CAAC;QACD,IAAI,cAAc,CAAC,SAAS,KAAK,MAAM,CAAC,SAAS,EAAE,CAAC;YAClD,YAAY,CAAC,IAAI,CAAC,6BAA6B,CAAC,CAAC;QACnD,CAAC;IACH,CAAC;IACD,MAAM,KAAK,GAAG,YAAY,CAAC,MAAM,GAAG,CAAC,CAAC;IAEtC,MAAM,KAAK,GAAiB,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,SAAS,EAAE,EAAE,EAAE,CAAC;IACxE,MAAM,IAAI,GAAG;QACX,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,YAAY,EAAE,iBAAiB,CAAC,MAAA,IAAI,CAAC,WAAW,mCAAI,EAAE,CAAC;KACxD,CAAC;IAEF,KAAK,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,WAAW,EAAE,CAAC;QAC7C,cAAc,CACZ,IAAI,CAAC,UAAU,EACf,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,IAAI,CAAC,EAC3B,YAAY,CAAC,QAAQ,EAAE,IAAI,CAAC,EAC5B,KAAK,EACL,IAAI,EACJ,IAAI,CAAC,KAAK,CACX,CAAC;IACJ,CAAC;IAED,cAAc,CACZ,IAAI,CAAC,UAAU,EACf,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,cAAc,CAAC,EACrC,eAAe,CAAC,MAAM,CAAC,EACvB,KAAK,EACL,cAAc,EACd,IAAI,CAAC,KAAK,CACX,CAAC;IAEF,uCACK,KAAK,KACR,UAAU;QACV,eAAe;QACf,cAAc;QACd,KAAK;QACL,YAAY,IACZ;AACJ,CAAC;AAED;;;;;;;;GAQG;AACH,SAAS,iBAAiB,CAAC,KAAwB;IACjD,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAClC,MAAM,KAAK,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,YAAY,gBAAgB,GAAG,IAAI,IAAI,CAAC,CAAC;IAC3E,OAAO,+CAA+C,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC;AAC7E,CAAC;AAED,kEAAkE;AAClE,MAAM,gBAAgB,GAAG,WAAW,CAAC;AAErC;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,oBAAoB,CAClC,UAAkB,EAClB,KAAc;IAEd,MAAM,IAAI,GAAG,YAAY,CAAC,UAAU,CAAC,CAAC;IAEtC,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;QACvB,MAAM,iBAAiB,CACrB,GAAG,UAAU,2DAA2D,EACxE,UAAU,UAAU,qDAAqD;YACvE,qBAAqB,CACxB,CAAC;IACJ,CAAC;IAED,IAAI,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,WAAW,EAAE,CAAC;QAC/C,MAAM,iBAAiB,CACrB,GAAG,UAAU,iCAAiC,EAC9C,kBAAkB,UAAU,6BAA6B,CAC1D,CAAC;IACJ,CAAC;IAED,IAAI,IAAI,KAAK,SAAS;QAAE,OAAO,IAAI,CAAC;IAEpC,MAAM,KAAK,GAAG,oBAAoB,CAAC,UAAU,CAAC,CAAC;IAE/C,yEAAyE;IACzE,IAAI,KAAK,CAAC,IAAI,KAAK,IAAI;QAAE,OAAO,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC;IACrD,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS,IAAI,KAAK;QAAE,OAAO,IAAI,CAAC;IAEnD,IAAI,EAAE,CAAC,WAAW,CAAC,UAAU,CAAC,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAEzD,MAAM,iBAAiB,CACrB,GAAG,UAAU,kCAAkC,cAAc,WAAW;QACtE,0CAA0C,EAC5C,UAAU,UAAU,6CAA6C;QAC/D,mEAAmE;QACnE,gCAAgC,CACnC,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,oBAAoB,CAAC,KAAa;IAChD,MAAM,UAAU,GAAG,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;IACxE,OAAO,UAAU,CAAC,OAAO,CAAC,oCAAoC,EAAE,EAAE,CAAC,CAAC;AACtE,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,kBAAkB,CAAC,KAAa;IAC9C,MAAM,SAAS,GAAG,oBAAoB,CAAC,KAAK,CAAC,CAAC;IAE9C,4CAA4C;IAC5C,IAAI,0BAA0B,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC;QAC/C,MAAM,iBAAiB,CACrB,uEAAuE;YACrE,wBAAwB,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,GAAG,EACtD,oEAAoE;YAClE,0DAA0D,CAC7D,CAAC;IACJ,CAAC;IAED,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,SAAS,EAAE,CAAC,CAAC;AAC1C,CAAC;AAQD;;;;;;;GAOG;AACH,MAAM,UAAU,sBAAsB,CACpC,UAAkB,EAClB,YAAwD;IAExD,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,uBAAuB,CAAC,CAAC;IAClE,MAAM,IAAI,GAAG,YAAY,CAAC,UAAU,CAAC,CAAC;IAEtC,2EAA2E;IAC3E,6EAA6E;IAC7E,0EAA0E;IAC1E,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;QACvB,MAAM,iBAAiB,CACrB,GAAG,UAAU,2DAA2D,EACxE,WAAW,UAAU,8CAA8C;YACjE,uCAAuC,CAC1C,CAAC;IACJ,CAAC;IAED,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;QACvB,OAAO,EAAE,YAAY,EAAE,KAAK,EAAE,UAAU,EAAE,CAAC;IAC7C,CAAC;IAED,iBAAiB,CAAC,UAAU,EAAE,UAAU,EAAE,mBAAmB,CAAC,YAAY,CAAC,CAAC,CAAC;IAE7E,OAAO,EAAE,YAAY,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC;AAC5C,CAAC;AAED;;;;;;;;GAQG;AACH,SAAS,mBAAmB,CAC1B,YAAwD;IAExD,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,MAAM,OAAO,GAAa,EAAE,CAAC;IAE7B,KAAK,MAAM,WAAW,IAAI,YAAY,EAAE,CAAC;QACvC,MAAM,KAAK,GAAG,iBAAiB,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;QAClD,OAAO,CAAC,IAAI,CACV,2BAA2B,KAAK,WAAW,kBAAkB,CAC3D,WAAW,CAAC,KAAK,CAClB,GAAG,CACL,CAAC;QACF,OAAO,CAAC,IAAI,CACV,YAAY,CAAC,8BAA8B,EAAE;YAC3C,KAAK,EAAE,KAAK;YACZ,IAAI,EAAE,WAAW,CAAC,IAAI;SACvB,CAAC,CACH,CAAC;IACJ,CAAC;IAED,OAAO,YAAY,CAAC,wBAAwB,EAAE;QAC5C,mBAAmB,EAAE,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC;QACvC,mBAAmB,EAAE,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;KACtC,CAAC,CAAC;AACL,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAAC,IAAY;IAC5C,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,cAAc,EAAE,CAAC,CAAC,EAAE,IAAY,EAAE,EAAE,CAC7D,IAAI,CAAC,WAAW,EAAE,CACnB,CAAC;IACF,OAAO,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC;AACpD,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,oBAAoB,CAAC,UAAkB;;IACrD,MAAM,aAAa,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,YAAY,CAAC,CAAC;IAC1D,MAAM,KAAK,GAAG,2EAA2E,eAAe,IAAI,CAAC;IAE7G,MAAM,IAAI,GAAG,YAAY,CAAC,aAAa,CAAC,CAAC;IAEzC,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;QACvB,MAAM,iBAAiB,CACrB,GAAG,aAAa,uDAAuD,EACvE,QAAQ,eAAe,iDAAiD;YACtE,GAAG,aAAa,iDAAiD,CACpE,CAAC;IACJ,CAAC;IAED,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;QACvB,iBAAiB,CAAC,UAAU,EAAE,aAAa,EAAE,KAAK,CAAC,CAAC;QACpD,OAAO,IAAI,CAAC;IACd,CAAC;IAED,MAAM,QAAQ,GAAG,MAAA,gBAAgB,CAAC,UAAU,EAAE,aAAa,CAAC,mCAAI,EAAE,CAAC;IACnE,IAAI,eAAe,CAAC,QAAQ,CAAC;QAAE,OAAO,KAAK,CAAC;IAE5C,MAAM,SAAS,GACb,QAAQ,CAAC,MAAM,KAAK,CAAC,IAAI,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;IAC/D,kBAAkB,CAAC,UAAU,EAAE,aAAa,EAAE,GAAG,SAAS,KAAK,KAAK,EAAE,CAAC,CAAC;IACxE,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAS,eAAe,CAAC,MAAc;IACrC,MAAM,WAAW,GAAG,IAAI,GAAG,CAAC,CAAC,SAAS,EAAE,UAAU,EAAE,UAAU,EAAE,WAAW,CAAC,CAAC,CAAC;IAC9E,OAAO,MAAM;SACV,KAAK,CAAC,IAAI,CAAC;SACX,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;SAC1B,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,WAAW,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC;AAC3C,CAAC","sourcesContent":["import * as fs from 'node:fs';\nimport * as path from 'node:path';\n\nimport { cliVersion, templatesFingerprint } from './compatibility';\nimport { invalidStateError, preconditionError } from './errors';\nimport {\n appendFileNoFollow,\n classifyPath,\n readFileNoFollow,\n writeFileNoFollow,\n} from './safe-fs';\nimport { readTemplate } from './templates';\n\n/** The dependency that owns every line of harness behaviour. */\nexport const HARNESS_PACKAGE = '@ekanos/harness';\n\n/** Where the generated shell lives, relative to the project root. */\nexport const HARNESS_DIR = path.join('.ekanos', 'harness');\n\n/** The single `.gitignore` line that covers the whole generated tree. */\nexport const GITIGNORE_ENTRY = '.ekanos/';\n\n/**\n * The staleness marker, inside the generated tree so deleting the tree also\n * deletes the marker. Holds the `@ekanos/harness` version that generated the\n * shell — a version bump means the shell may be wired against an older export\n * surface, so it is regenerated.\n */\nexport const VERSION_MARKER = '.ekanos-version';\n\n/** The partner-owned registry, at the PROJECT ROOT. Never regenerated. */\nexport const HARNESS_CONFIG_FILENAME = 'harness.config.ts';\n\n/**\n * Every file in the generated shell: bundled template → path inside\n * `.ekanos/harness/`. The ten route files are mechanically derivable from the\n * export map alone — which is the test of whether the package boundary is in\n * the right place. If a shell file ever needs a judgment call, the boundary\n * has moved, not the template.\n *\n * `app/api/integrations/[slug]/[...path]/route.ts` is the shell's one SERVER\n * route: it injects the registry into `@ekanos/harness/api`, which serves the\n * live-mode OAuth loop, storage bridge and trigger route over a server-held\n * context. It is the third registry injection point, beside the two pages.\n *\n * `app/harness-shell.tsx` exists because a `HarnessIntegration` holds React\n * component references and MCP `run` functions. Next cannot serialize those\n * across a server→client boundary, so the `'use client'` mark has to sit ABOVE\n * the point where the registry is injected — hence a client shell of its own\n * rather than a client `RootLayout` called from a server layout. Keeping\n * `app/page.tsx` a server component is what lets `/` prerender static.\n */\nexport const SHELL_FILES: readonly { template: string; dest: string }[] = [\n { template: 'harness/app/layout.tsx.tmpl', dest: 'app/layout.tsx' },\n {\n template: 'harness/app/harness-shell.tsx.tmpl',\n dest: 'app/harness-shell.tsx',\n },\n { template: 'harness/app/page.tsx.tmpl', dest: 'app/page.tsx' },\n {\n template: 'harness/app/[slug]/layout.tsx.tmpl',\n dest: 'app/[slug]/layout.tsx',\n },\n {\n template: 'harness/app/[slug]/widgets/page.tsx.tmpl',\n dest: 'app/[slug]/widgets/page.tsx',\n },\n {\n template: 'harness/app/[slug]/widgets/[widgetId]/page.tsx.tmpl',\n dest: 'app/[slug]/widgets/[widgetId]/page.tsx',\n },\n {\n template: 'harness/app/[slug]/tile/page.tsx.tmpl',\n dest: 'app/[slug]/tile/page.tsx',\n },\n {\n template: 'harness/app/[slug]/activation/page.tsx.tmpl',\n dest: 'app/[slug]/activation/page.tsx',\n },\n {\n template: 'harness/app/[slug]/triggers/page.tsx.tmpl',\n dest: 'app/[slug]/triggers/page.tsx',\n },\n {\n template: 'harness/app/api/integrations/[slug]/[...path]/route.ts.tmpl',\n dest: 'app/api/integrations/[slug]/[...path]/route.ts',\n },\n { template: 'harness/next.config.mjs.tmpl', dest: 'next.config.mjs' },\n { template: 'harness/postcss.config.mjs.tmpl', dest: 'postcss.config.mjs' },\n { template: 'harness/tsconfig.json.tmpl', dest: 'tsconfig.json' },\n { template: 'harness/styles/globals.css.tmpl', dest: 'styles/globals.css' },\n { template: 'harness/package.json.tmpl', dest: 'package.json' },\n {\n template: 'harness/types/team-accounts.d.ts.tmpl',\n dest: 'types/team-accounts.d.ts',\n },\n];\n\n/**\n * Packages the shell needs to resolve upward out of the partner's own\n * `node_modules`. The shell declares no dependencies of its own by design, so\n * a missing one shows up as an opaque Next resolution error at dev-server\n * start; checking here turns that into one precondition with one fix.\n */\nexport const REQUIRED_DEPENDENCIES = [\n HARNESS_PACKAGE,\n 'next',\n 'react',\n 'react-dom',\n 'tailwindcss',\n '@tailwindcss/postcss',\n] as const;\n\nexport interface WrittenFiles {\n created: string[];\n updated: string[];\n unchanged: string[];\n}\n\n/**\n * Read an installed package's `package.json` out of a project's TOP-LEVEL\n * `node_modules`. Resolved by direct path join rather than `require.resolve`\n * on purpose, for two independent reasons:\n *\n * - The CLI runs from its own install and must read the version of the copy\n * in THE CALLER'S project, not one hoisted next to itself.\n * - Several of the packages this reads (`@ekanos/integration-schema`\n * included) declare an `exports` map with no `./package.json` subpath, so\n * `require.resolve('<pkg>/package.json')` throws\n * `ERR_PACKAGE_PATH_NOT_EXPORTED` under Node's ESM resolver. A plain file\n * read is not subject to the export map at all.\n *\n * Shared by every \"what does this project have installed?\" check in the\n * CLI — `readInstalledHarness` below, the schema-skew gate, and `upgrade`'s\n * current-toolchain report — so there is exactly one implementation of \"read\n * a package's on-disk version\", not a subtly different one per call site.\n */\nexport interface InstalledPackage {\n version: string;\n /** The raw package.json, for callers that need more than the version. */\n manifest: unknown;\n}\n\nexport function readInstalledPackageManifest(\n projectDir: string,\n packageName: string,\n): InstalledPackage | null {\n const manifestPath = path.join(\n projectDir,\n 'node_modules',\n ...packageName.split('/'),\n 'package.json',\n );\n if (!fs.existsSync(manifestPath)) return null;\n\n try {\n const parsed: unknown = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));\n const version =\n typeof parsed === 'object' && parsed !== null\n ? (parsed as { version?: unknown }).version\n : undefined;\n if (typeof version !== 'string' || version.length === 0) return null;\n return { version, manifest: parsed };\n } catch (error) {\n throw preconditionError(\n `${packageName}'s package.json in node_modules is unreadable: ${\n error instanceof Error ? error.message : String(error)\n }`,\n `Reinstall dependencies in ${projectDir} so ${packageName} resolves.`,\n );\n }\n}\n\n/** Read the installed `@ekanos/harness` version out of the partner's project. */\nexport type InstalledHarness = InstalledPackage;\n\nexport function readInstalledHarness(\n projectDir: string,\n): InstalledHarness | null {\n return readInstalledPackageManifest(projectDir, HARNESS_PACKAGE);\n}\n\n/** Just the installed version, for callers that need nothing else. */\nexport function readInstalledHarnessVersion(projectDir: string): string | null {\n return readInstalledHarness(projectDir)?.version ?? null;\n}\n\n/** Which of `REQUIRED_DEPENDENCIES` are not installed in the project root. */\nexport function missingDependencies(projectDir: string): string[] {\n return REQUIRED_DEPENDENCIES.filter(\n (name) =>\n !fs.existsSync(\n path.join(projectDir, 'node_modules', name, 'package.json'),\n ),\n );\n}\n\n/**\n * What generated an existing shell.\n *\n * All three fields matter, and recording only `harness` was a real hole: the\n * templates ship with the CLI, so a shell can be stale because the CLI changed\n * while the harness did not. `templates` is the authoritative one — it is a\n * hash of the template sources, so it moves whenever what we generate moves,\n * including for a CLI build that never shipped a version bump.\n */\nexport interface ShellMarker {\n harness: string;\n /** null for a legacy bare-version marker written before this was recorded. */\n cli: string | null;\n templates: string | null;\n}\n\n/**\n * Three outcomes, not two, because \"no marker\" and \"unreadable marker\" call\n * for opposite responses.\n *\n * A marker's PRESENCE is itself evidence the tree is ours — a squatter does\n * not write `.ekanos-version` — and the likeliest cause of an unreadable one\n * is our own interrupted write. Everything under `.ekanos/harness/` is\n * CLI-owned, gitignored and reproducible from the templates, so regenerating\n * destroys nothing: there is no value at risk to justify charging a partner a\n * confusing `--force` to recover from our own bad write.\n *\n * An ABSENT marker over a non-empty tree is the opposite: nothing says the\n * files are ours, so we refuse. That is the L1 rule and it stays.\n */\nexport type MarkerState =\n | { kind: 'missing' }\n | { kind: 'corrupt' }\n | { kind: 'ok'; marker: ShellMarker };\n\nexport function readShellMarkerState(harnessDir: string): MarkerState {\n const marker = path.join(harnessDir, VERSION_MARKER);\n if (classifyPath(marker) !== 'file') return { kind: 'missing' };\n\n const raw = (readFileNoFollow(harnessDir, marker) ?? '').trim();\n if (raw.length === 0) return { kind: 'corrupt' };\n\n const parsed = parseMarker(raw);\n return parsed === null ? { kind: 'corrupt' } : { kind: 'ok', marker: parsed };\n}\n\nfunction parseMarker(raw: string): ShellMarker | null {\n // v1 markers were a bare version string. Read them rather than crashing, and\n // report the unknown fields as null so the shell counts as stale and is\n // regenerated once — which is the correct outcome for an upgrade.\n //\n // It must actually LOOK like a version: without this, any garbage that does\n // not happen to start with `{` — a truncated write, a stray word — reads as\n // a legacy marker named after the garbage, and the corrupt-marker path never\n // fires. That is how a truncated `.ekanos-version` was silently reported as\n // \"@ekanos/harness truncat → 0.1.0\" instead of \"unreadable\".\n if (!raw.startsWith('{')) {\n return /^\\d+\\.\\d+/.test(raw)\n ? { harness: raw, cli: null, templates: null }\n : null;\n }\n\n try {\n const parsed: unknown = JSON.parse(raw);\n if (typeof parsed !== 'object' || parsed === null) return null;\n const record = parsed as Record<string, unknown>;\n const harness = record.harness;\n if (typeof harness !== 'string') return null;\n return {\n harness,\n cli: typeof record.cli === 'string' ? record.cli : null,\n templates: typeof record.templates === 'string' ? record.templates : null,\n };\n } catch {\n return null;\n }\n}\n\n/** The parsed marker, or null when it is absent OR unreadable. */\nexport function readShellMarker(harnessDir: string): ShellMarker | null {\n const state = readShellMarkerState(harnessDir);\n return state.kind === 'ok' ? state.marker : null;\n}\n\n/** The `@ekanos/harness` version an existing shell was generated against. */\nexport function readShellVersion(harnessDir: string): string | null {\n return readShellMarker(harnessDir)?.harness ?? null;\n}\n\nfunction serializeMarker(marker: ShellMarker): string {\n return `${JSON.stringify(marker, null, 2)}\\n`;\n}\n\n/**\n * Write a generated file, but ONLY when its bytes would change.\n *\n * This is what makes a re-run cheap and a watcher quiet: an unchanged file is\n * not rewritten, so its mtime does not move and Next's dev server does not\n * rebuild. It also means a hand-edit inside the CLI-owned tree is silently\n * corrected on the next run, which is the intended contract for `.ekanos/`.\n */\nexport function writeIfChanged(\n root: string,\n destPath: string,\n contents: string,\n into: WrittenFiles,\n label: string,\n force: boolean,\n): void {\n // `classifyPath` uses lstat, so a symlink here is reported as a symlink\n // rather than as whatever it points at — including a DANGLING one, which\n // `existsSync` would call absent and a plain write would then materialize\n // outside the project.\n const kind = classifyPath(destPath);\n const exists = kind !== 'missing';\n\n if (\n !force &&\n kind === 'file' &&\n readFileNoFollow(root, destPath) === contents\n ) {\n into.unchanged.push(label);\n return;\n }\n\n writeFileNoFollow(root, destPath, contents);\n (exists ? into.updated : into.created).push(label);\n}\n\nexport interface ScaffoldShellArgs {\n projectDir: string;\n slug: string;\n harnessVersion: string;\n /** Rewrite every file even when its bytes already match. */\n force: boolean;\n /** Extra Tailwind @source globs from ekanos.json, already validated. */\n sourceGlobs?: readonly string[];\n}\n\nexport interface ScaffoldShellResult extends WrittenFiles {\n harnessDir: string;\n /** The version the shell carried before this run — null on a first run. */\n previousVersion: string | null;\n /** What generated the previous shell, or null on a first run. */\n previousMarker: ShellMarker | null;\n /** True when anything that generated the shell has since changed. */\n stale: boolean;\n /**\n * Which of harness / cli / templates moved. Empty on a first run; this is\n * what lets `dev` say WHY it regenerated rather than just that it did.\n */\n staleReasons: string[];\n}\n\n/**\n * Generate `.ekanos/harness/**` for `harnessVersion`. Every file here is\n * CLI-owned and gitignored, so regeneration needs no permission and asks no\n * question — the partner owns `harness.config.ts` and their integration\n * source, and nothing else.\n */\nexport function scaffoldShell(args: ScaffoldShellArgs): ScaffoldShellResult {\n const harnessDir = path.join(args.projectDir, HARNESS_DIR);\n assertShellDirIsOurs(harnessDir, args.force);\n\n const markerState = readShellMarkerState(harnessDir);\n const previousMarker = markerState.kind === 'ok' ? markerState.marker : null;\n const previousVersion = previousMarker?.harness ?? null;\n const marker: ShellMarker = {\n harness: args.harnessVersion,\n cli: cliVersion(),\n templates: templatesFingerprint(SHELL_FILES.map((f) => f.template)),\n };\n\n // A shell is current only if EVERYTHING that produced it is unchanged.\n // Checking the harness version alone certified shells generated by an older\n // CLI, against templates that had since moved, as up to date.\n const staleReasons: string[] = [];\n if (markerState.kind === 'corrupt') {\n staleReasons.push(\n `${VERSION_MARKER} was unreadable, so the existing shell could not be ` +\n 'certified',\n );\n }\n if (previousMarker !== null) {\n if (previousMarker.harness !== marker.harness) {\n staleReasons.push(\n `@ekanos/harness ${previousMarker.harness} → ${marker.harness}`,\n );\n }\n if (previousMarker.cli !== marker.cli) {\n staleReasons.push(\n `@ekanos/cli ${previousMarker.cli ?? 'unknown'} → ${marker.cli}`,\n );\n }\n if (previousMarker.templates !== marker.templates) {\n staleReasons.push('the shell templates changed');\n }\n }\n const stale = staleReasons.length > 0;\n\n const files: WrittenFiles = { created: [], updated: [], unchanged: [] };\n const vars = {\n SLUG: args.slug,\n SOURCE_GLOBS: renderSourceGlobs(args.sourceGlobs ?? []),\n };\n\n for (const { template, dest } of SHELL_FILES) {\n writeIfChanged(\n args.projectDir,\n path.join(harnessDir, dest),\n readTemplate(template, vars),\n files,\n dest,\n args.force,\n );\n }\n\n writeIfChanged(\n args.projectDir,\n path.join(harnessDir, VERSION_MARKER),\n serializeMarker(marker),\n files,\n VERSION_MARKER,\n args.force,\n );\n\n return {\n ...files,\n harnessDir,\n previousVersion,\n previousMarker,\n stale,\n staleReasons,\n };\n}\n\n/**\n * Turn validated `sourceGlobs` into the `@source` lines the generated\n * stylesheet carries, each relative to the shell's `styles/` directory.\n *\n * Tailwind never scans what it is not pointed at, and a glob that matches\n * nothing fails silently — so an integration living outside the default\n * layout renders completely unstyled with no error anywhere. That is what\n * `sourceGlobs` is for, and until now it was parsed and thrown away.\n */\nfunction renderSourceGlobs(globs: readonly string[]): string {\n if (globs.length === 0) return '';\n const lines = globs.map((glob) => `@source '${SHELL_TO_PROJECT}${glob}';`);\n return `\\n/* From \"sourceGlobs\" in ekanos.json. */\\n${lines.join('\\n')}\\n`;\n}\n\n/** From `.ekanos/harness/styles/` back up to the project root. */\nconst SHELL_TO_PROJECT = '../../../';\n\n/**\n * Establish that `.ekanos/harness` is a tree WE generated before writing a\n * single byte into it, and return the version marker it carried.\n *\n * Path location alone is not ownership. A directory that exists but carries no\n * `.ekanos-version` was put there by something else — a partner, another tool,\n * a symlink — and clobbering it because it happens to sit at the path we like\n * is the same class of mistake as following a symlink. An empty directory is\n * fine (that is just `mkdir -p` having run), and `--force` is the explicit\n * override.\n *\n * Runs BEFORE `bootstrapHarnessConfig` in `dev`, so a shell that cannot be\n * written leaves the project completely untouched rather than half-scaffolded.\n */\nexport function assertShellDirIsOurs(\n harnessDir: string,\n force: boolean,\n): string | null {\n const kind = classifyPath(harnessDir);\n\n if (kind === 'symlink') {\n throw invalidStateError(\n `${harnessDir} is a symlink, so \"ekanos dev\" will not write through it.`,\n `Delete ${harnessDir} and re-run \"ekanos dev\" — the shell is generated, ` +\n 'so nothing is lost.',\n );\n }\n\n if (kind !== 'missing' && kind !== 'directory') {\n throw invalidStateError(\n `${harnessDir} exists and is not a directory.`,\n `Move or delete ${harnessDir}, then re-run \"ekanos dev\".`,\n );\n }\n\n if (kind === 'missing') return null;\n\n const state = readShellMarkerState(harnessDir);\n\n // A marker we wrote, however mangled, still identifies the tree as ours.\n if (state.kind === 'ok') return state.marker.harness;\n if (state.kind === 'corrupt' || force) return null;\n\n if (fs.readdirSync(harnessDir).length === 0) return null;\n\n throw invalidStateError(\n `${harnessDir} already contains files but no ${VERSION_MARKER} marker, ` +\n 'so it was not generated by \"ekanos dev\".',\n `Delete ${harnessDir} if you do not need it, or pass --force to ` +\n 'overwrite it. Everything \"ekanos dev\" generates is reproducible; ' +\n 'anything else in there is not.',\n );\n}\n\n/**\n * The module specifier `harness.config.ts` uses to import the partner's\n * integration: the `entry` from ekanos.json, made relative and extension-free.\n * \"src/integration.ts\" → \"src/integration\".\n */\nexport function entryImportSpecifier(entry: string): string {\n const normalized = entry.split(path.sep).join('/').replace(/^\\.\\//, '');\n return normalized.replace(/\\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/, '');\n}\n\n/**\n * The same specifier as a JavaScript string LITERAL, ready to drop into a\n * generated `import` statement.\n *\n * `entry` is attacker-influenced data from `ekanos.json`, and a POSIX filename\n * may legally contain a quote or a newline. Interpolating it raw would let a\n * crafted-but-valid path close the string and append statements to the module\n * we generate. `JSON.stringify` produces a correctly escaped literal, which is\n * the actual fix; the explicit rejection below is a second line that also\n * gives the partner a comprehensible error instead of a bizarre filename\n * silently appearing, escaped, in their config.\n *\n * This does not cross a trust boundary on its own — a partner's own code runs\n * unsandboxed anyway — but \"the templates cannot be injected into\" is a claim\n * we make, and it should be true.\n */\nexport function entryImportLiteral(entry: string): string {\n const specifier = entryImportSpecifier(entry);\n\n // eslint-disable-next-line no-control-regex\n if (/[\"'`\\\\\\n\\r\\u0000-\\u001f]/.test(specifier)) {\n throw preconditionError(\n `The \"entry\" in ekanos.json contains characters that cannot appear in ` +\n `an import specifier: ${JSON.stringify(specifier)}.`,\n 'Rename the file so its path has no quotes, backslashes or control ' +\n 'characters, then update \"entry\" in ekanos.json to match.',\n );\n }\n\n return JSON.stringify(`./${specifier}`);\n}\n\nexport interface BootstrapConfigResult {\n /** True only when this run created the file. */\n bootstrapped: boolean;\n configPath: string;\n}\n\n/**\n * Write a starter `harness.config.ts` at the project root — ONCE.\n *\n * An existing file is left strictly alone, byte for byte, and no flag changes\n * that: it is the one harness file the partner owns, it is git-tracked, and\n * regenerating it would throw away their fixtures. `--force` regenerates the\n * shell, never this.\n */\nexport function bootstrapHarnessConfig(\n projectDir: string,\n integrations: readonly { slug: string; entry: string }[],\n): BootstrapConfigResult {\n const configPath = path.join(projectDir, HARNESS_CONFIG_FILENAME);\n const kind = classifyPath(configPath);\n\n // A symlink here — including a dangling one, which `existsSync` reports as\n // absent — would have us write through to its target. Since this file is the\n // partner's to own, the only safe answer is to touch nothing and say why.\n if (kind === 'symlink') {\n throw invalidStateError(\n `${configPath} is a symlink, so \"ekanos dev\" will not write through it.`,\n `Replace ${configPath} with a real file (or delete it to have one ` +\n 'generated), then re-run \"ekanos dev\".',\n );\n }\n\n if (kind !== 'missing') {\n return { bootstrapped: false, configPath };\n }\n\n writeFileNoFollow(projectDir, configPath, renderHarnessConfig(integrations));\n\n return { bootstrapped: true, configPath };\n}\n\n/**\n * Render the starter registry for EVERY integration the project declares.\n *\n * The harness registry is an array and `ekanos.json` can now hold a list, so a\n * project's second integration reaches the harness the same way its first\n * does. Each entry imports the definition under a distinct local identifier\n * derived from its slug, because two `integration` imports in one module do\n * not compile.\n */\nfunction renderHarnessConfig(\n integrations: readonly { slug: string; entry: string }[],\n): string {\n const imports: string[] = [];\n const entries: string[] = [];\n\n for (const integration of integrations) {\n const ident = identifierForSlug(integration.slug);\n imports.push(\n `import { integration as ${ident} } from ${entryImportLiteral(\n integration.entry,\n )};`,\n );\n entries.push(\n readTemplate('harness.config.entry.ts.tmpl', {\n IDENT: ident,\n SLUG: integration.slug,\n }),\n );\n }\n\n return readTemplate('harness.config.ts.tmpl', {\n INTEGRATION_IMPORTS: imports.join('\\n'),\n INTEGRATION_ENTRIES: entries.join(''),\n });\n}\n\n/**\n * \"acme-crm\" → \"acmeCrm\". A slug is kebab-case by schema, so this only has to\n * handle hyphens, and a leading digit is impossible.\n */\nexport function identifierForSlug(slug: string): string {\n const camel = slug.replace(/-([a-z0-9])/g, (_, char: string) =>\n char.toUpperCase(),\n );\n return /^[0-9]/.test(camel) ? `i${camel}` : camel;\n}\n\n/**\n * Add `.ekanos/` to the project `.gitignore`, exactly once.\n *\n * Matching is line-wise over the spellings git treats as the same rule\n * (`.ekanos`, `.ekanos/`, `/.ekanos/`), so re-running never appends a\n * duplicate — the thing that makes a scaffold verb safe to run in a loop.\n * A negation (`!.ekanos/`) is deliberately NOT treated as a match: the\n * partner meant to un-ignore it, and we do not fight that silently.\n */\nexport function ensureGitignoreEntry(projectDir: string): boolean {\n const gitignorePath = path.join(projectDir, '.gitignore');\n const block = `# Ekanos dev harness — generated by \"ekanos dev\", never edited by hand\\n${GITIGNORE_ENTRY}\\n`;\n\n const kind = classifyPath(gitignorePath);\n\n if (kind === 'symlink') {\n throw invalidStateError(\n `${gitignorePath} is a symlink, so \"ekanos dev\" will not append to it.`,\n `Add \"${GITIGNORE_ENTRY}\" to your real .gitignore yourself, or replace ` +\n `${gitignorePath} with a regular file, then re-run \"ekanos dev\".`,\n );\n }\n\n if (kind === 'missing') {\n writeFileNoFollow(projectDir, gitignorePath, block);\n return true;\n }\n\n const existing = readFileNoFollow(projectDir, gitignorePath) ?? '';\n if (gitignoreCovers(existing)) return false;\n\n const separator =\n existing.length === 0 || existing.endsWith('\\n') ? '' : '\\n';\n appendFileNoFollow(projectDir, gitignorePath, `${separator}\\n${block}`);\n return true;\n}\n\nfunction gitignoreCovers(source: string): boolean {\n const equivalents = new Set(['.ekanos', '.ekanos/', '/.ekanos', '/.ekanos/']);\n return source\n .split('\\n')\n .map((line) => line.trim())\n .some((line) => equivalents.has(line));\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ekanos/cli",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "The Ekanos partner toolchain CLI: scaffold, validate, and test a Fusion integration against the published @ekanos packages. Agent-native — every verb speaks JSON with a stable exit-code taxonomy.",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@clack/prompts": "1.7.0",
36
- "@ekanos/integration-schema": "0.2.0",
36
+ "@ekanos/integration-schema": "0.3.0",
37
37
  "esbuild": "0.28.1",
38
38
  "picocolors": "1.1.1"
39
39
  },
@@ -27,6 +27,7 @@ export const integration = defineIntegration<MyStorage>({
27
27
  permissions: [ /* disclosure of what you read/write */ ],
28
28
  components: { widgets: [ /* … */ ] },
29
29
  tools: [ /* assistant-callable functions */ ],
30
+ actions: { /* deterministic, widget-invoked server handlers */ },
30
31
  webhooks: [ /* inbound deliveries */ ],
31
32
  schedules: [ /* cron runs */ ],
32
33
  onActivate: async (ctx) => { /* seed caches, sanity-check credentials */ },
@@ -172,6 +173,76 @@ failure). Schedule results: `{ status: 'completed' }` / `{ status: 'skipped' }`
172
173
  (`invocation.trigger` distinguishes them). Cron is 5-field numeric only — no
173
174
  names, no `@daily`, no seconds.
174
175
 
176
+ ### Actions
177
+
178
+ `actions` is a record — keyed by kebab-case action name (`'create-record'`,
179
+ not `tools`' `snake_case`) — of deterministic, widget-invoked server
180
+ handlers. It is a **parallel surface to `tools`, not a flag on it**: a
181
+ widget's Save button already has fully-typed, user-entered values, so it
182
+ calls your handler directly instead of routing a natural-language prompt
183
+ through the assistant and hoping the model picks the right tool with the
184
+ right arguments.
185
+
186
+ ```ts
187
+ actions: {
188
+ 'create-record': {
189
+ description: 'Create a record in the base.',
190
+ input: CreateRecordInput, // a real zod schema
191
+ output: CreateRecordOutput, // optional — validates what handler resolves to
192
+ handler: async (ctx, input) => {
193
+ // Same ctx every tool/webhook/schedule handler gets: ctx.fetch,
194
+ // ctx.secrets, ctx.storage — runs server-side, never in the browser.
195
+ return createRecordViaApi(ctx, input);
196
+ },
197
+ },
198
+ },
199
+ ```
200
+
201
+ `input` (and the optional `output`) are real `z.ZodTypeAny` instances, not
202
+ the JSON-schema-shaped `parameters` a tool declares for LLM argument
203
+ synthesis — an action is only ever parsed in-process, never serialized to a
204
+ model. Export the inferred types alongside the declaration
205
+ (`export type CreateRecordInput = z.infer<typeof CreateRecordInput>`) so the
206
+ widget importing them gets real autocomplete, per the SDK typing note below.
207
+
208
+ **Invoke it from a widget with `useIntegrationAction()`** (`@ekanos/sdk/hooks`)
209
+ — never `useAskAssistant()` for this. `useAskAssistant()` sends a
210
+ conversational question to the assistant dock (`Widget.AskFooter`'s "ask
211
+ anything about this widget"); it is not a write path and does not call your
212
+ handler deterministically. A form's Save button calls the handler you
213
+ declared, with the exact values the user typed:
214
+
215
+ ```tsx
216
+ const createRecord = useIntegrationAction<CreateRecordInput, CreateRecordOutput>(
217
+ '__SLUG__',
218
+ 'create-record',
219
+ { accountId },
220
+ );
221
+
222
+ <Button onClick={() => createRecord.mutate(formValues)} disabled={createRecord.isPending}>
223
+ Save
224
+ </Button>
225
+ ```
226
+
227
+ A rejected mutation's `error` is always an `IntegrationActionError`
228
+ (`status`, `code`, and — for a 400 — your own declared zod `issues`), never a
229
+ raw fetch exception. The route answers a fixed set of `code`s your widget can
230
+ branch on without string-matching: `action_not_found`, `invalid_input`,
231
+ `rate_limited`, `handler_error` (plus the client-assigned `network_error` /
232
+ `invalid_response` for a request that never reached a valid envelope).
233
+
234
+ A handler that wants its OWN message shown to the end user verbatim (e.g.
235
+ "That record already exists") throws `PublicActionError`
236
+ (`@ekanos/sdk/integration`) — the one escape hatch from the route's default
237
+ opaque `handler_error` message. Every other throw collapses to a generic
238
+ "Action failed." on the wire, though the real error is always logged
239
+ server-side.
240
+
241
+ Reuse one handler as both an action and a tool without double-declaring
242
+ logic — export the handler once and reference it from both declarations
243
+ (`run: (ctx, args) => createRecord.handler(ctx, CreateRecordInput.parse(args))`)
244
+ if you also want the assistant able to call it.
245
+
175
246
  ### `onActivate`
176
247
 
177
248
  Runs once after an activation is first persisted, and again after
@@ -222,6 +293,12 @@ harness and in production. A widget reads declared `clientReadable`
222
293
  account-scope storage back with `fetchIntegrationStorage()` from
223
294
  `@ekanos/sdk/hooks`.
224
295
 
296
+ For a widget that WRITES — a form's Save button, not a read — reach for
297
+ `useIntegrationAction()` (see "Actions" above), not `useAskAssistant()`.
298
+ `useAskAssistant()` sends a conversational question to the assistant dock;
299
+ it is not a deterministic write path and gives no guarantee the model calls
300
+ your handler with the values the user actually typed.
301
+
225
302
  Build widget UI from `@ekanos/ui` — the published slice of the host design
226
303
  system. Available modules (import individually, e.g. `@ekanos/ui/button`):
227
304
  `alert`, `badge`, `button`, `card`, `dialog`, `dropdown-menu`, `form`, `icon`,
@@ -267,26 +344,58 @@ live: {
267
344
  ```
268
345
 
269
346
  Egress is read off the definition automatically — do not repeat it here.
270
-
271
- What stays mocked either way (the Fusion bits): storage, secrets/vault,
272
- auth, host `/api/` routes — that is what `seeds` and fixtures are for.
273
- Boundaries to know: live-mode widget requests come from the BROWSER, so a
274
- vendor that blocks browser origins (CORS) or needs a server-held secret
275
- must be exercised through handlers instead; and the Triggers surface runs
276
- webhook/schedule/onActivate handlers against the mock context (its
277
- `ctx.fetch` never touches the network) — cover handler network behavior
278
- with fixtures in tests, then for real on the dev host after publishing.
347
+ Add `default: 'live'` to that block ONLY when your widgets never call the
348
+ vendor from the browser (every vendor call is server-side behind `ctx.fetch`,
349
+ the OAuth shape): the harness then opens in Live API, and nothing fires at the
350
+ vendor until the user acts. A choice made at the toolbar always wins.
351
+
352
+ There is still no Fusion database or auth in either mode. What differs is
353
+ who answers `/api/integrations/<slug>/…`:
354
+
355
+ - **Fixtures**: your fixtures do, in the browser. Storage, secrets and the
356
+ OAuth status (`oauth.connected` in the harness entry, connected by default)
357
+ are all mocked; `ctx.fetch` in handlers answers from fixtures and never
358
+ touches the network.
359
+ - **Live**: the generated shell's own API routes do (`@ekanos/harness/api`),
360
+ over ONE server-held context per integration. That context's `ctx.fetch`
361
+ reaches the real vendor under your declared `egress`; its secrets come from
362
+ `.ekanos/local-secrets.json` (written by the **Local OAuth** panel's form on
363
+ the Activation surface — one field per declared secret name; never commit
364
+ it) plus whatever `onTokens` stored; its storage is
365
+ whatever your handlers wrote. The Triggers and Activation surfaces run
366
+ webhooks, schedules and `onActivate` on that context, and the **OAuth loop
367
+ runs for real**. The Activation surface's **Local OAuth** panel guides the
368
+ human part — a checklist for the provider's developer console (register
369
+ `http://localhost:3100/api/integrations/<slug>/oauth/callback`, enable the
370
+ declared scopes, copy the keys) derived from the declaration plus the
371
+ entry's `oauth.setup` (console link, provider-specific notes — WRITE these
372
+ for every OAuth integration), then a form that saves the keys server-side.
373
+ Then Connect, and `onTokens` runs on the server. The connection is
374
+ memory-only — a dev-server restart means one more Connect. **Never take a
375
+ user's client id, secret or token through chat or write one into a file;
376
+ the form is the only path.**
377
+
378
+ Boundaries to know: live-mode WIDGET requests come from the browser, so a
379
+ vendor that blocks browser origins (CORS) or needs a server-held secret must be
380
+ exercised through handlers and read back through storage — which is exactly
381
+ what the live context is for.
279
382
 
280
383
  ### Network model
281
384
 
282
385
  Two different fetches, two different guarantees:
283
386
 
284
- - **`ctx.fetch` (handlers — `onActivate`/webhooks/schedules/tools).** Real
285
- enforcement, in the harness AND in production: your `egress` allowlist,
286
- redirect re-vetting, IP pinning. Locally, handlers only ever run on
287
- Triggers/Activate against the mock context, so their `ctx.fetch` never
288
- leaves your machine regardless of the toolbar — real network only after
289
- you publish to a dev host.
387
+ - **`ctx.fetch` (handlers — `onActivate`/webhooks/schedules/tools/actions).**
388
+ Real enforcement, in the harness AND in production: your `egress`
389
+ allowlist, redirect re-vetting, IP pinning. Locally it depends on the
390
+ toolbar: in fixtures mode handlers run in the browser against the mock
391
+ context and `ctx.fetch` answers from your fixtures, never the network; in
392
+ live mode they run on the dev server's live context and `ctx.fetch` reaches
393
+ the real vendor, egress-checked first — which is how the OAuth code
394
+ exchange and a post-connect sync work locally at all. An `actions[name]`
395
+ handler is executed exactly the same way as a tool or webhook handler — a
396
+ widget's `useIntegrationAction()` call never reaches the vendor directly;
397
+ it always crosses through your server-side handler and `ctx.fetch` first,
398
+ in the harness and in production alike.
290
399
  - **`useIntegrationFetch()` (widgets, browser-side).** In the harness, live
291
400
  mode routes it through the same egress check as a convenience, enforced
292
401
  centrally rather than something you configure per widget. **That check
@@ -298,8 +407,16 @@ Two different fetches, two different guarantees:
298
407
  means to rule out.
299
408
 
300
409
  In short: widgets get real network in Live API mode, gated by convenience in
301
- the harness and by nothing in production; handlers get fixtures locally and
302
- the real, enforced thing only after publish.
410
+ the harness and by nothing in production; handlers get fixtures in fixtures
411
+ mode and the real, enforced thing in live mode — locally on the dev server,
412
+ then on the host after publish. `actions` handlers follow the handler rule:
413
+ `useIntegrationAction()` in the harness runs your handler against fixtures
414
+ by default, same as `invokeWebhook`/`invokeSchedule` — live execution is an
415
+ explicit, per-session opt-in from the dev toolbar (a confirmation the first
416
+ time a call would touch the real vendor that session, not a per-call
417
+ prompt), never the default. In production there is only one mode: the
418
+ handler always runs server-side with the real `IntegrationContext` and real
419
+ `ctx.fetch` egress enforcement.
303
420
 
304
421
  ## Testing
305
422
 
@@ -19,7 +19,12 @@ in the same commit when the live API disagrees.
19
19
 
20
20
  ## The loop
21
21
 
22
- 1. `npx ekanos dev` — local harness (only when you need to see UI).
22
+ 1. `npx ekanos dev` — local harness (only when you need to see UI). For an
23
+ OAuth integration it is also where the USER connects: the Activation
24
+ surface's Local OAuth panel walks them through their provider console and
25
+ takes their client id/secret in a form. Point them there; never ask for a
26
+ secret in chat or write one into a file (`build-integration` →
27
+ `references/oauth-setup.md`).
23
28
  2. Edit code. Never use global `fetch`/`process.env`/`fs` in integration
24
29
  code — everything goes through `ctx`.
25
30
  3. `npx tsc --noEmit` — types clean.
@@ -0,0 +1,9 @@
1
+ import { createHarnessApiHandlers } from '@ekanos/harness/api';
2
+
3
+ import { harnessIntegrations } from '../../../../../../../harness.config';
4
+
5
+ const handlers = createHarnessApiHandlers(harnessIntegrations);
6
+
7
+ export const GET = handlers.GET;
8
+ export const POST = handlers.POST;
9
+ export const PUT = handlers.PUT;
@@ -15,8 +15,8 @@
15
15
  "@ekanos/ui": "^0.2.0"
16
16
  },
17
17
  "devDependencies": {
18
- "@ekanos/cli": "^0.2.2",
19
- "@ekanos/harness": "^0.1.5",
18
+ "@ekanos/cli": "^0.3.0",
19
+ "@ekanos/harness": "^0.2.0",
20
20
  "@hookform/resolvers": "^5.2.2",
21
21
  "@tailwindcss/postcss": "^4.3.3",
22
22
  "@tanstack/react-query": "^5.101.4",
@@ -52,11 +52,16 @@ otherwise ask in plain prose with lettered options.
52
52
  3. **The outcomes** (free text): "What should a team see on their dashboard,
53
53
  and what should the integration be able to do?" Push for 1–3 concrete
54
54
  outcomes ("show open invoices", "alert on new disputes", "let the
55
- assistant create a customer"). Each SEE-outcome becomes a widget; each
56
- DO-outcome becomes a tool, webhook, or schedule.
55
+ assistant create a customer", "let someone create a record from a
56
+ dashboard form"). Each SEE-outcome becomes a widget; each DO-outcome
57
+ becomes a tool (assistant-invoked), a webhook/schedule (event-driven), or
58
+ an action (a widget's own deterministic write — a form's Save button).
57
59
  4. **Auth** (choice): API key / Bearer / OAuth 2.0 / Basic / not sure
58
60
  (research will settle it). This decides the activation-form fields — and
59
61
  every string field name on that form IS a secret name (see AGENTS.md).
62
+ For OAuth 2.0, also ask whether they have a developer/sandbox app already;
63
+ during research, find the console page where one is created — you will
64
+ put it in `harness.config.ts` (see `references/oauth-setup.md`).
60
65
  5. **Freshness** (choice): live fetch on view / cached with a refresh
61
66
  schedule / pushed by webhooks. This decides storage keys and schedules.
62
67
 
@@ -131,7 +136,13 @@ notes, update `docs/api-notes.md` in the same commit as the code fix.
131
136
 
132
137
  Order: activation form (field names = secret names, keep them in exported
133
138
  constants) → server handlers using `ctx.fetch` / `ctx.storage` /
134
- `ctx.secrets` only → tools → webhooks/schedules. Write tests alongside each
139
+ `ctx.secrets` only → tools → webhooks/schedules → actions (deterministic
140
+ widget writes — a form's Save button, invoked with `useIntegrationAction()`,
141
+ never routed through the assistant). For an OAuth integration,
142
+ also read `references/oauth-setup.md`: it tells you what to put on the
143
+ harness entry so the harness walks the user through their provider's console,
144
+ and how to hand the user off to the Local OAuth panel — the user's secrets
145
+ never pass through you. Write tests alongside each
135
146
  handler with `createMockContext` / `invokeWebhook` / `invokeSchedule` from
136
147
  `@ekanos/sdk/testing`, and run:
137
148
 
@@ -145,7 +156,9 @@ All of these, in order, all green:
145
156
  2. `npx ekanos test` — all passing, including at least one test per handler
146
157
  and one failure-path test per external call
147
158
  3. Harness check — every widget renders all four states (mock the states if
148
- needed); user has seen and approved the visuals
159
+ needed); user has seen and approved the visuals. For OAuth: the user has
160
+ completed the Local OAuth panel (checklist → Save credentials → Connect)
161
+ and the widgets render THEIR sandbox data in Live API
149
162
  4. Grep your own diff for leaks: no credential in `ctx.storage`, `configData`,
150
163
  logs, or tool results; no global `fetch`/`process.env`/`node:fs`
151
164
 
@@ -30,11 +30,23 @@ code. This file is the order of work and the traps.
30
30
  `fetchIntegrationStorage()` from `@ekanos/sdk/hooks`. Cache freshness in
31
31
  the stored value (`fetchedAt`) and show it in the UI when staleness
32
32
  matters.
33
+ 6. **Widget writes** — the read path's mirror. A form's Save button declares
34
+ an `actions[name]` handler (zod `input`, optional `output`, `handler(ctx,
35
+ input)`) and calls it with `useIntegrationAction()` from
36
+ `@ekanos/sdk/hooks` — deterministic, with the values the user actually
37
+ typed, never a natural-language hand-off to the assistant. A handler that
38
+ wants its own message shown to the user verbatim throws
39
+ `PublicActionError` from `@ekanos/sdk/integration`; every other throw
40
+ collapses to a generic error on the wire (still logged in full
41
+ server-side). See "Actions" in AGENTS.md.
33
42
 
34
43
  ## Traps, each one earned
35
44
 
36
45
  - **Egress is an allowlist, checked synchronously.** Every origin the client
37
46
  touches must be in `egress` — including the OAuth token host and any host a
47
+ For OAuth integrations, `oauth-setup.md` in this folder covers the human
48
+ setup and the harness's Local OAuth panel — the user's client id and secret
49
+ go into that form, never into chat or a file you write.
38
50
  redirect lands on. A missing origin is a thrown error, not a warning.
39
51
  - **Secrets never leave the secrets surface.** Not into `ctx.storage`, not
40
52
  into logs, not into a tool result, not into a widget's stored value. Token
@@ -91,4 +103,8 @@ on its `harness.config.ts` entry — see AGENTS.md "Live mode") and the
91
103
  toolbar's fixtures ⇄ live switch runs real widget queries against the real
92
104
  API, gated only by the declared `egress`. Use fixtures for the Fusion-side
93
105
  state widgets need first, and for webhook/schedule handlers (the Triggers
94
- surface runs those against the mock context by design).
106
+ surface runs those against the mock context by design). `actions[name]`
107
+ handlers follow the same rule: `useIntegrationAction()` calls run against
108
+ fixtures by default, and only reach the real vendor after an explicit,
109
+ per-session live-mode confirmation from the toolbar — never on the first
110
+ call by accident.
@@ -0,0 +1,76 @@
1
+ # OAuth integrations — set the human up, never handle their secrets
2
+
3
+ An OAuth integration has a step no code can do: a person signs in to the
4
+ provider's developer console, creates an app, registers a redirect URI,
5
+ enables scopes, and copies a client id and secret out. Your job is to make
6
+ that step obvious and short, and to keep the secrets out of the
7
+ conversation and out of every file you write.
8
+
9
+ ## What you write (in `harness.config.ts`, on the integration's entry)
10
+
11
+ ```ts
12
+ oauth: {
13
+ // Fixtures mode answers the SDK's /oauth/status route from this; `empty`
14
+ // usually wants the Connect prompt rather than empty widgets.
15
+ connected: { empty: false },
16
+ // The human checklist the harness cannot derive from the declaration:
17
+ // where the console is, and the provider's own names for each key.
18
+ setup: {
19
+ consoleUrl: 'https://<provider developer console>/apps',
20
+ consoleLabel: '<Provider> → Apps and Keys',
21
+ notes: [
22
+ '<Provider's name for the client id> is the client id (<clientIdSecretName>).',
23
+ '<Where the secret is minted, and whether it is shown once> (<clientSecretSecretName>).',
24
+ // One line per thing a first-time user would otherwise get wrong.
25
+ ],
26
+ },
27
+ },
28
+ // When every vendor call runs server-side behind ctx.fetch (the OAuth shape)
29
+ // and widgets only talk to the harness's own routes, open in Live API:
30
+ live: { default: 'live' },
31
+ ```
32
+
33
+ Find `consoleUrl` and the notes in the provider's docs during Phase 2
34
+ (research) — the "create an app" / "register an application" page. Write
35
+ what the CONSOLE calls things, not what the declaration calls them; the
36
+ harness already prints the declared secret names beside each field. The
37
+ harness derives the rest — provider host, redirect URI, scopes, PKCE — from
38
+ `defineIntegration({ oauth })`, so do not repeat those in `notes`.
39
+
40
+ ## What the harness then shows (so you can point the user at it)
41
+
42
+ The Activation surface (`npx ekanos dev` → `http://localhost:3100/<slug>/activation`)
43
+ opens with a **Local OAuth** panel:
44
+
45
+ 1. the human checklist — console link, the redirect URI to register (Copy
46
+ button), the scopes to enable, PKCE, which key goes where, your notes;
47
+ 2. a form with one password field per declared secret name and a **Save
48
+ credentials** button, written server-side to a gitignored file and never
49
+ echoed back;
50
+ 3. the connect dialog, whose **Connect** runs the real authorize → callback →
51
+ `onTokens` loop against the provider's developer sandbox.
52
+
53
+ ## How to guide the user through it
54
+
55
+ - After the declaration validates and the harness entry has `oauth.setup`,
56
+ tell the user in ONE message: open the Activation surface, follow the
57
+ checklist in the panel, paste the two keys into the form, click Connect.
58
+ Name the console and the redirect URI in the message so they can start
59
+ before the harness loads.
60
+ - **Never ask for a client id, client secret, token, or signing key in
61
+ chat, and never write one into a file** — not `harness.config.ts`, not
62
+ `triggerMocks.secrets` (those are FAKE seeds and are bundled into the
63
+ browser build), not `.env`, not `.ekanos/local-secrets.json` by hand. The
64
+ form is the only path; if the user pastes a secret into the conversation,
65
+ tell them to rotate it in the console and use the form.
66
+ - The connection is memory-only: after a dev-server restart, "not
67
+ connected" is expected — say so instead of debugging.
68
+ - `<slug>_oauth_error=<code>` on the return URL is the transport's
69
+ vocabulary: `not_configured` = a key is still missing (check the panel's
70
+ badges), `denied` = the user cancelled at the provider, `exchange_failed` =
71
+ the provider rejected the exchange (wrong redirect URI or secret, or the
72
+ token host missing from `egress`), `save_failed` = your `onTokens` threw
73
+ (read the dev-server log). Fix the cause; do not retry blindly.
74
+ - Widgets read the connection through `useOAuthWidgetGate` / the storage
75
+ route. If they show "Connect" after a successful loop, check `onActivate`
76
+ or the schedule wrote the `clientReadable` keys the widgets read.
@@ -35,6 +35,14 @@ name the question for is a widget you cannot design.
35
35
  | Chart | "Which way is it going?" | The chart + current value; axes and legends muted |
36
36
  | Action / connection | "What can I do / who's connected?" | One clear CTA or the identity + status |
37
37
 
38
+ A widget whose CTA WRITES something (a form, a "Create record" button) calls
39
+ a declared `actions[name]` handler via `useIntegrationAction()` — a
40
+ deterministic RPC with the values the user actually typed, not a
41
+ conversational hand-off. Reach for `useAskAssistant()` only for an actual
42
+ "ask about this" affordance (`Widget.AskFooter`); it is not a write path and
43
+ gives no guarantee the assistant calls anything with the right arguments.
44
+ See "Actions" in `AGENTS.md`.
45
+
38
46
  The default failure is uniform weight: everything `text-sm`, values dressed
39
47
  like labels. Squint at the render — if nothing reads first, there is no
40
48
  hierarchy, and no amount of padding will save it.
@@ -74,8 +82,10 @@ Every widget: `'use client'`, `Widget.DataState` wrapping `Widget.Card` →
74
82
  fetching on `ctx?.state.state === 'active'` from `WidgetContext`, and fetch
75
83
  with `useIntegrationFetch()` from `@ekanos/sdk/hooks` — never global
76
84
  `fetch` — reaching for `useActivationData()` (same module) if the widget
77
- needs a client-visible credential. Copy the canonical example from the SDK
78
- README rather than improvising the tree.
85
+ needs a client-visible credential. For a write (a form submit), call
86
+ `useIntegrationAction()` (same module) instead — it posts to your declared
87
+ `actions[name]` handler, not the vendor directly. Copy the canonical example
88
+ from the SDK README rather than improvising the tree.
79
89
 
80
90
  `Widget.Active` itself renders flush (no padding) so full-bleed charts and
81
91
  tables can run edge-to-edge. For ordinary content — text, rows, a KPI
@@ -80,4 +80,20 @@ export const integration = defineIntegration({
80
80
  },
81
81
  },
82
82
  ],
83
+
84
+ // `actions` is the deterministic counterpart to `tools`: a widget's own
85
+ // Save button calls a declared handler directly (via `useIntegrationAction()`
86
+ // from `@ekanos/sdk/hooks`), with the exact values the user typed — never a
87
+ // natural-language hand-off to the assistant. Uncomment and adapt once a
88
+ // widget needs to write something; see "Actions" in AGENTS.md.
89
+ //
90
+ // actions: {
91
+ // 'create-record': {
92
+ // description: 'Create a record in the base.',
93
+ // input: z.object({ name: z.string().min(1) }),
94
+ // handler: async (_ctx, input) => {
95
+ // return { id: 'rec_123', name: input.name };
96
+ // },
97
+ // },
98
+ // },
83
99
  });