@ekanos/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +326 -0
- package/dist/auth/credential-store.d.ts +96 -0
- package/dist/auth/credential-store.js +274 -0
- package/dist/auth/credential-store.js.map +1 -0
- package/dist/auth/device-flow.d.ts +57 -0
- package/dist/auth/device-flow.js +201 -0
- package/dist/auth/device-flow.js.map +1 -0
- package/dist/auth/fusion-api.d.ts +48 -0
- package/dist/auth/fusion-api.js +310 -0
- package/dist/auth/fusion-api.js.map +1 -0
- package/dist/auth/session.d.ts +57 -0
- package/dist/auth/session.js +114 -0
- package/dist/auth/session.js.map +1 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +20 -0
- package/dist/bin.js.map +1 -0
- package/dist/commands/dev.d.ts +36 -0
- package/dist/commands/dev.js +207 -0
- package/dist/commands/dev.js.map +1 -0
- package/dist/commands/init.d.ts +30 -0
- package/dist/commands/init.js +178 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/login.d.ts +20 -0
- package/dist/commands/login.js +92 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/logout.d.ts +20 -0
- package/dist/commands/logout.js +75 -0
- package/dist/commands/logout.js.map +1 -0
- package/dist/commands/test.d.ts +16 -0
- package/dist/commands/test.js +54 -0
- package/dist/commands/test.js.map +1 -0
- package/dist/commands/validate.d.ts +14 -0
- package/dist/commands/validate.js +98 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/commands/whoami.d.ts +18 -0
- package/dist/commands/whoami.js +29 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/compatibility.d.ts +81 -0
- package/dist/compatibility.js +135 -0
- package/dist/compatibility.js.map +1 -0
- package/dist/context.d.ts +81 -0
- package/dist/context.js +125 -0
- package/dist/context.js.map +1 -0
- package/dist/errors.d.ts +41 -0
- package/dist/errors.js +111 -0
- package/dist/errors.js.map +1 -0
- package/dist/exit-codes.d.ts +52 -0
- package/dist/exit-codes.js +51 -0
- package/dist/exit-codes.js.map +1 -0
- package/dist/harness-scaffold.d.ts +212 -0
- package/dist/harness-scaffold.js +434 -0
- package/dist/harness-scaffold.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +262 -0
- package/dist/index.js.map +1 -0
- package/dist/load-definition.d.ts +20 -0
- package/dist/load-definition.js +126 -0
- package/dist/load-definition.js.map +1 -0
- package/dist/merge-manifest.d.ts +44 -0
- package/dist/merge-manifest.js +124 -0
- package/dist/merge-manifest.js.map +1 -0
- package/dist/package-manager.d.ts +25 -0
- package/dist/package-manager.js +57 -0
- package/dist/package-manager.js.map +1 -0
- package/dist/parse-argv.d.ts +27 -0
- package/dist/parse-argv.js +74 -0
- package/dist/parse-argv.js.map +1 -0
- package/dist/project-checks.d.ts +18 -0
- package/dist/project-checks.js +77 -0
- package/dist/project-checks.js.map +1 -0
- package/dist/project.d.ts +140 -0
- package/dist/project.js +228 -0
- package/dist/project.js.map +1 -0
- package/dist/safe-fs.d.ts +55 -0
- package/dist/safe-fs.js +209 -0
- package/dist/safe-fs.js.map +1 -0
- package/dist/templates.d.ts +23 -0
- package/dist/templates.js +49 -0
- package/dist/templates.js.map +1 -0
- package/package.json +62 -0
- package/templates/ekanos.json.tmpl +4 -0
- package/templates/gitignore.tmpl +12 -0
- package/templates/harness/app/[slug]/activation/page.tsx.tmpl +1 -0
- package/templates/harness/app/[slug]/layout.tsx.tmpl +1 -0
- package/templates/harness/app/[slug]/tile/page.tsx.tmpl +1 -0
- package/templates/harness/app/[slug]/triggers/page.tsx.tmpl +1 -0
- package/templates/harness/app/[slug]/widgets/[widgetId]/page.tsx.tmpl +1 -0
- package/templates/harness/app/[slug]/widgets/page.tsx.tmpl +1 -0
- package/templates/harness/app/harness-shell.tsx.tmpl +9 -0
- package/templates/harness/app/layout.tsx.tmpl +4 -0
- package/templates/harness/app/page.tsx.tmpl +7 -0
- package/templates/harness/next.config.mjs.tmpl +46 -0
- package/templates/harness/package.json.tmpl +11 -0
- package/templates/harness/postcss.config.mjs.tmpl +6 -0
- package/templates/harness/styles/globals.css.tmpl +33 -0
- package/templates/harness/tsconfig.json.tmpl +31 -0
- package/templates/harness/types/team-accounts.d.ts.tmpl +16 -0
- package/templates/harness.config.entry.ts.tmpl +65 -0
- package/templates/harness.config.ts.tmpl +58 -0
- package/templates/package.json.tmpl +28 -0
- package/templates/src/integration.test.ts.tmpl +24 -0
- package/templates/src/integration.ts.tmpl +83 -0
- package/templates/tsconfig.json.tmpl +19 -0
- package/templates/vitest.config.ts.tmpl +32 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { revokeRefreshToken } from '../auth/device-flow.js';
|
|
2
|
+
import { fetchCliAuthConfig } from '../auth/fusion-api.js';
|
|
3
|
+
import { newStore, resolveAuthEnvironment } from '../auth/session.js';
|
|
4
|
+
/**
|
|
5
|
+
* `logout` — revoke the stored refresh token at Auth0, then delete it locally.
|
|
6
|
+
*
|
|
7
|
+
* Idempotent: logging out twice exits 0. `cleared: false` in the envelope says
|
|
8
|
+
* there was nothing to remove; it is information, not a failure.
|
|
9
|
+
*
|
|
10
|
+
* Revocation is attempted first but never blocks the local delete. A logout
|
|
11
|
+
* that only forgets the token would leave a rotating credential live for weeks;
|
|
12
|
+
* a logout that REFUSES because the network is down would leave the token on
|
|
13
|
+
* disk, which is worse. So: try to revoke, always delete, report both.
|
|
14
|
+
*/
|
|
15
|
+
export async function runLogout(ctx, args) {
|
|
16
|
+
if (args.all) {
|
|
17
|
+
return runLogoutAll(ctx, args);
|
|
18
|
+
}
|
|
19
|
+
const env = resolveAuthEnvironment(ctx, args.host, args.env);
|
|
20
|
+
const session = env.store.read(env.host);
|
|
21
|
+
if (!session) {
|
|
22
|
+
return ctx.succeed({ host: env.host, cleared: false, revoked: false }, `logout: no stored session for ${env.host}.`);
|
|
23
|
+
}
|
|
24
|
+
const revoked = await tryRevoke(ctx, env.host, session.auth0RefreshToken);
|
|
25
|
+
env.store.clear(env.host);
|
|
26
|
+
if (!revoked) {
|
|
27
|
+
ctx.warn(`ekanos: the local credentials were removed, but the refresh token ` +
|
|
28
|
+
`could not be revoked at Auth0. It stays valid until it expires — ` +
|
|
29
|
+
`revoke the session in Auth0 if this machine is compromised.`);
|
|
30
|
+
}
|
|
31
|
+
return ctx.succeed({ host: env.host, cleared: true, revoked }, `logout: signed out of ${env.host}${revoked ? ' and revoked the session' : ''}.`);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Never throws. Every failure mode here — an unreachable host, a deployment
|
|
35
|
+
* without the CLI routes, an Auth0 outage — must still end in the local token
|
|
36
|
+
* being deleted.
|
|
37
|
+
*/
|
|
38
|
+
async function tryRevoke(ctx, host, refreshToken) {
|
|
39
|
+
try {
|
|
40
|
+
const config = await fetchCliAuthConfig(host);
|
|
41
|
+
return await revokeRefreshToken(config, refreshToken);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
// Diagnostic only — the message never carries the token, and the outcome
|
|
45
|
+
// is reported in the envelope's `revoked` field either way.
|
|
46
|
+
ctx.warn(`ekanos: could not reach ${host} to revoke the session ` +
|
|
47
|
+
`(${error instanceof Error ? error.message : String(error)}).`);
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* `logout --all` — the "I am leaving this machine" verb. Every host is revoked
|
|
53
|
+
* independently and the local file is emptied regardless, for the same reason
|
|
54
|
+
* the single-host path does not let a failed revocation block the delete.
|
|
55
|
+
*/
|
|
56
|
+
async function runLogoutAll(ctx, args) {
|
|
57
|
+
const store = newStore(ctx, args.env);
|
|
58
|
+
const hosts = store.hosts();
|
|
59
|
+
if (hosts.length === 0) {
|
|
60
|
+
return ctx.succeed({ hosts: [], cleared: 0 }, 'logout: no stored sessions.');
|
|
61
|
+
}
|
|
62
|
+
const results = [];
|
|
63
|
+
for (const host of hosts) {
|
|
64
|
+
const session = store.read(host);
|
|
65
|
+
results.push({
|
|
66
|
+
host,
|
|
67
|
+
revoked: session
|
|
68
|
+
? await tryRevoke(ctx, host, session.auth0RefreshToken)
|
|
69
|
+
: false,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const cleared = store.clearAll();
|
|
73
|
+
return ctx.succeed({ hosts: results, cleared }, `logout: signed out of ${cleared} host${cleared === 1 ? '' : 's'}.`);
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=logout.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"logout.js","sourceRoot":"","sources":["../../src/commands/logout.ts"],"names":[],"mappings":"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","sourcesContent":["import { revokeRefreshToken } from '../auth/device-flow';\nimport { fetchCliAuthConfig } from '../auth/fusion-api';\nimport { newStore, resolveAuthEnvironment } from '../auth/session';\nimport type { CliContext } from '../context';\nimport type { ExitCode } from '../exit-codes';\n\nexport interface LogoutArgs {\n host?: string;\n /** Sign out of every host in the credentials file. */\n all: boolean;\n env: Record<string, string | undefined>;\n}\n\n/**\n * `logout` — revoke the stored refresh token at Auth0, then delete it locally.\n *\n * Idempotent: logging out twice exits 0. `cleared: false` in the envelope says\n * there was nothing to remove; it is information, not a failure.\n *\n * Revocation is attempted first but never blocks the local delete. A logout\n * that only forgets the token would leave a rotating credential live for weeks;\n * a logout that REFUSES because the network is down would leave the token on\n * disk, which is worse. So: try to revoke, always delete, report both.\n */\nexport async function runLogout(\n ctx: CliContext,\n args: LogoutArgs,\n): Promise<ExitCode> {\n if (args.all) {\n return runLogoutAll(ctx, args);\n }\n\n const env = resolveAuthEnvironment(ctx, args.host, args.env);\n const session = env.store.read(env.host);\n\n if (!session) {\n return ctx.succeed(\n { host: env.host, cleared: false, revoked: false },\n `logout: no stored session for ${env.host}.`,\n );\n }\n\n const revoked = await tryRevoke(ctx, env.host, session.auth0RefreshToken);\n\n env.store.clear(env.host);\n\n if (!revoked) {\n ctx.warn(\n `ekanos: the local credentials were removed, but the refresh token ` +\n `could not be revoked at Auth0. It stays valid until it expires — ` +\n `revoke the session in Auth0 if this machine is compromised.`,\n );\n }\n\n return ctx.succeed(\n { host: env.host, cleared: true, revoked },\n `logout: signed out of ${env.host}${revoked ? ' and revoked the session' : ''}.`,\n );\n}\n\n/**\n * Never throws. Every failure mode here — an unreachable host, a deployment\n * without the CLI routes, an Auth0 outage — must still end in the local token\n * being deleted.\n */\nasync function tryRevoke(\n ctx: CliContext,\n host: string,\n refreshToken: string,\n): Promise<boolean> {\n try {\n const config = await fetchCliAuthConfig(host);\n\n return await revokeRefreshToken(config, refreshToken);\n } catch (error) {\n // Diagnostic only — the message never carries the token, and the outcome\n // is reported in the envelope's `revoked` field either way.\n ctx.warn(\n `ekanos: could not reach ${host} to revoke the session ` +\n `(${error instanceof Error ? error.message : String(error)}).`,\n );\n\n return false;\n }\n}\n\n/**\n * `logout --all` — the \"I am leaving this machine\" verb. Every host is revoked\n * independently and the local file is emptied regardless, for the same reason\n * the single-host path does not let a failed revocation block the delete.\n */\nasync function runLogoutAll(\n ctx: CliContext,\n args: LogoutArgs,\n): Promise<ExitCode> {\n const store = newStore(ctx, args.env);\n const hosts = store.hosts();\n\n if (hosts.length === 0) {\n return ctx.succeed(\n { hosts: [], cleared: 0 },\n 'logout: no stored sessions.',\n );\n }\n\n const results: { host: string; revoked: boolean }[] = [];\n\n for (const host of hosts) {\n const session = store.read(host);\n\n results.push({\n host,\n revoked: session\n ? await tryRevoke(ctx, host, session.auth0RefreshToken)\n : false,\n });\n }\n\n const cleared = store.clearAll();\n\n return ctx.succeed(\n { hosts: results, cleared },\n `logout: signed out of ${cleared} host${cleared === 1 ? '' : 's'}.`,\n );\n}\n"]}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CliContext } from '../context.js';
|
|
2
|
+
export interface TestArgs {
|
|
3
|
+
dir: string;
|
|
4
|
+
/** argv after `--`, forwarded verbatim to the runner. */
|
|
5
|
+
passthrough: string[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* `test` — delegate to the partner's own test script; do NOT reimplement or
|
|
9
|
+
* parse the runner's output. We detect the package manager from the lockfile,
|
|
10
|
+
* spawn `<pm> run test` with any post-`--` args appended, stream the child's
|
|
11
|
+
* output to OUR stderr (keeping stdout pure for the single JSON envelope), and
|
|
12
|
+
* report `{ command, exitCode, durationMs }`.
|
|
13
|
+
*
|
|
14
|
+
* The CLI's exit code is a transparent passthrough of the runner's exit code.
|
|
15
|
+
*/
|
|
16
|
+
export declare function runTest(ctx: CliContext, args: TestArgs): Promise<number>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { detectPackageManager } from '../package-manager.js';
|
|
3
|
+
import { loadProject } from '../project.js';
|
|
4
|
+
/**
|
|
5
|
+
* `test` — delegate to the partner's own test script; do NOT reimplement or
|
|
6
|
+
* parse the runner's output. We detect the package manager from the lockfile,
|
|
7
|
+
* spawn `<pm> run test` with any post-`--` args appended, stream the child's
|
|
8
|
+
* output to OUR stderr (keeping stdout pure for the single JSON envelope), and
|
|
9
|
+
* report `{ command, exitCode, durationMs }`.
|
|
10
|
+
*
|
|
11
|
+
* The CLI's exit code is a transparent passthrough of the runner's exit code.
|
|
12
|
+
*/
|
|
13
|
+
export async function runTest(ctx, args) {
|
|
14
|
+
const loaded = loadProject(args.dir);
|
|
15
|
+
const pm = detectPackageManager(loaded.projectDir);
|
|
16
|
+
const runnerArgs = ['run', 'test', ...args.passthrough];
|
|
17
|
+
const command = `${pm.bin} ${runnerArgs.join(' ')}`;
|
|
18
|
+
ctx.log(`Running "${command}" in ${loaded.projectDir}…`);
|
|
19
|
+
const started = Date.now();
|
|
20
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
21
|
+
var _a, _b;
|
|
22
|
+
const child = spawn(pm.bin, runnerArgs, {
|
|
23
|
+
cwd: loaded.projectDir,
|
|
24
|
+
// stdin inherited; the child's stdout/stderr are piped so we can route
|
|
25
|
+
// them to OUR stderr and keep stdout reserved for the JSON envelope.
|
|
26
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
27
|
+
});
|
|
28
|
+
// In JSON mode every byte of runner output goes to stderr; in human mode we
|
|
29
|
+
// keep the child's stream separation.
|
|
30
|
+
(_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (chunk) => {
|
|
31
|
+
(ctx.jsonMode ? process.stderr : process.stdout).write(chunk);
|
|
32
|
+
});
|
|
33
|
+
(_b = child.stderr) === null || _b === void 0 ? void 0 : _b.on('data', (chunk) => {
|
|
34
|
+
process.stderr.write(chunk);
|
|
35
|
+
});
|
|
36
|
+
child.on('error', (error) => reject(error));
|
|
37
|
+
child.on('close', (code) => resolve(code !== null && code !== void 0 ? code : 1));
|
|
38
|
+
});
|
|
39
|
+
const durationMs = Date.now() - started;
|
|
40
|
+
const data = { command, exitCode, durationMs };
|
|
41
|
+
if (exitCode === 0) {
|
|
42
|
+
return ctx.succeed(data, `test: OK — "${command}" passed in ${durationMs}ms.`);
|
|
43
|
+
}
|
|
44
|
+
return ctx.failWith({
|
|
45
|
+
code: 'test_failed',
|
|
46
|
+
message: `"${command}" exited with code ${exitCode}.`,
|
|
47
|
+
hint: 'Read the runner output on stderr and fix the failing tests, then ' +
|
|
48
|
+
're-run "ekanos test".',
|
|
49
|
+
// Transparent passthrough of the runner's own exit code.
|
|
50
|
+
exitCode,
|
|
51
|
+
data,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"test.js","sourceRoot":"","sources":["../../src/commands/test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAG3C,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAQzC;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,OAAO,CAC3B,GAAe,EACf,IAAc;IAEd,MAAM,MAAM,GAAG,WAAW,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACrC,MAAM,EAAE,GAAG,oBAAoB,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;IAEnD,MAAM,UAAU,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC,WAAW,CAAC,CAAC;IACxD,MAAM,OAAO,GAAG,GAAG,EAAE,CAAC,GAAG,IAAI,UAAU,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;IACpD,GAAG,CAAC,GAAG,CAAC,YAAY,OAAO,QAAQ,MAAM,CAAC,UAAU,GAAG,CAAC,CAAC;IAEzD,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAC3B,MAAM,QAAQ,GAAG,MAAM,IAAI,OAAO,CAAS,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;;QAC7D,MAAM,KAAK,GAAG,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,UAAU,EAAE;YACtC,GAAG,EAAE,MAAM,CAAC,UAAU;YACtB,uEAAuE;YACvE,qEAAqE;YACrE,KAAK,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,CAAC;SACnC,CAAC,CAAC;QAEH,4EAA4E;QAC5E,sCAAsC;QACtC,MAAA,KAAK,CAAC,MAAM,0CAAE,EAAE,CAAC,MAAM,EAAE,CAAC,KAAa,EAAE,EAAE;YACzC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QAChE,CAAC,CAAC,CAAC;QACH,MAAA,KAAK,CAAC,MAAM,0CAAE,EAAE,CAAC,MAAM,EAAE,CAAC,KAAa,EAAE,EAAE;YACzC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QAC9B,CAAC,CAAC,CAAC;QAEH,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;QAC5C,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,aAAJ,IAAI,cAAJ,IAAI,GAAI,CAAC,CAAC,CAAC,CAAC;IAClD,CAAC,CAAC,CAAC;IAEH,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO,CAAC;IACxC,MAAM,IAAI,GAAG,EAAE,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,CAAC;IAE/C,IAAI,QAAQ,KAAK,CAAC,EAAE,CAAC;QACnB,OAAO,GAAG,CAAC,OAAO,CAChB,IAAI,EACJ,eAAe,OAAO,eAAe,UAAU,KAAK,CACrD,CAAC;IACJ,CAAC;IAED,OAAO,GAAG,CAAC,QAAQ,CAAC;QAClB,IAAI,EAAE,aAAa;QACnB,OAAO,EAAE,IAAI,OAAO,sBAAsB,QAAQ,GAAG;QACrD,IAAI,EACF,mEAAmE;YACnE,uBAAuB;QACzB,yDAAyD;QACzD,QAAQ;QACR,IAAI;KACL,CAAC,CAAC;AACL,CAAC","sourcesContent":["import { spawn } from 'node:child_process';\n\nimport type { CliContext } from '../context';\nimport { detectPackageManager } from '../package-manager';\nimport { loadProject } from '../project';\n\nexport interface TestArgs {\n dir: string;\n /** argv after `--`, forwarded verbatim to the runner. */\n passthrough: string[];\n}\n\n/**\n * `test` — delegate to the partner's own test script; do NOT reimplement or\n * parse the runner's output. We detect the package manager from the lockfile,\n * spawn `<pm> run test` with any post-`--` args appended, stream the child's\n * output to OUR stderr (keeping stdout pure for the single JSON envelope), and\n * report `{ command, exitCode, durationMs }`.\n *\n * The CLI's exit code is a transparent passthrough of the runner's exit code.\n */\nexport async function runTest(\n ctx: CliContext,\n args: TestArgs,\n): Promise<number> {\n const loaded = loadProject(args.dir);\n const pm = detectPackageManager(loaded.projectDir);\n\n const runnerArgs = ['run', 'test', ...args.passthrough];\n const command = `${pm.bin} ${runnerArgs.join(' ')}`;\n ctx.log(`Running \"${command}\" in ${loaded.projectDir}…`);\n\n const started = Date.now();\n const exitCode = await new Promise<number>((resolve, reject) => {\n const child = spawn(pm.bin, runnerArgs, {\n cwd: loaded.projectDir,\n // stdin inherited; the child's stdout/stderr are piped so we can route\n // them to OUR stderr and keep stdout reserved for the JSON envelope.\n stdio: ['inherit', 'pipe', 'pipe'],\n });\n\n // In JSON mode every byte of runner output goes to stderr; in human mode we\n // keep the child's stream separation.\n child.stdout?.on('data', (chunk: Buffer) => {\n (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 const durationMs = Date.now() - started;\n const data = { command, exitCode, durationMs };\n\n if (exitCode === 0) {\n return ctx.succeed(\n data,\n `test: OK — \"${command}\" passed in ${durationMs}ms.`,\n );\n }\n\n return ctx.failWith({\n code: 'test_failed',\n message: `\"${command}\" exited with code ${exitCode}.`,\n hint:\n 'Read the runner output on stderr and fix the failing tests, then ' +\n 're-run \"ekanos test\".',\n // Transparent passthrough of the runner's own exit code.\n exitCode,\n data,\n });\n}\n"]}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CliContext } from '../context.js';
|
|
2
|
+
import type { ExitCode } from '../exit-codes.js';
|
|
3
|
+
export interface ValidateArgs {
|
|
4
|
+
/** Project directory (contains ekanos.json). Defaults to cwd. */
|
|
5
|
+
dir: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* `validate` — parse the declaration with the REAL zod schemas from
|
|
9
|
+
* `@ekanos/integration-schema` (never a reimplementation), gather structured
|
|
10
|
+
* findings, and add project-level integrity checks (the vitest inline-SDK
|
|
11
|
+
* check). Exit 0 with `findings: []` when clean; exit 3 when any error-severity
|
|
12
|
+
* finding is present, with the findings carried in the envelope's `data`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function runValidate(ctx: CliContext, args: ValidateArgs): Promise<ExitCode>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { collectCollisionFindings, collectDefinitionFindings, } from '@ekanos/integration-schema';
|
|
2
|
+
import { preconditionError, validationError } from '../errors.js';
|
|
3
|
+
import { loadDefinition } from '../load-definition.js';
|
|
4
|
+
import { loadProject } from '../project.js';
|
|
5
|
+
import { collectProjectFindings } from '../project-checks.js';
|
|
6
|
+
/**
|
|
7
|
+
* `validate` — parse the declaration with the REAL zod schemas from
|
|
8
|
+
* `@ekanos/integration-schema` (never a reimplementation), gather structured
|
|
9
|
+
* findings, and add project-level integrity checks (the vitest inline-SDK
|
|
10
|
+
* check). Exit 0 with `findings: []` when clean; exit 3 when any error-severity
|
|
11
|
+
* finding is present, with the findings carried in the envelope's `data`.
|
|
12
|
+
*/
|
|
13
|
+
export async function runValidate(ctx, args) {
|
|
14
|
+
const loaded = loadProject(args.dir);
|
|
15
|
+
const slugs = loaded.integrations.map((i) => i.slug).join(', ');
|
|
16
|
+
ctx.log(`Validating ${loaded.integrations.length} integration` +
|
|
17
|
+
`${loaded.integrations.length === 1 ? '' : 's'} (${slugs})…`);
|
|
18
|
+
const findings = [];
|
|
19
|
+
const definitions = [];
|
|
20
|
+
for (const integration of loaded.integrations) {
|
|
21
|
+
const result = await loadDefinition(integration.entryPath, loaded.projectDir);
|
|
22
|
+
if (result.ok) {
|
|
23
|
+
findings.push(...collectDefinitionFindings(result.definition, {
|
|
24
|
+
file: integration.entryPath,
|
|
25
|
+
}));
|
|
26
|
+
findings.push(...collectSlugAgreementFindings(integration, result.definition));
|
|
27
|
+
definitions.push(result.definition);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (result.kind === 'rejected') {
|
|
31
|
+
// The module loaded but the SDK/schema rejected the definition at import
|
|
32
|
+
// time — surface it as a validation finding rather than a crash.
|
|
33
|
+
findings.push({
|
|
34
|
+
check: 'definition.load',
|
|
35
|
+
severity: 'error',
|
|
36
|
+
file: integration.entryPath,
|
|
37
|
+
message: result.message,
|
|
38
|
+
hint: 'Fix the integration definition so defineIntegration() accepts it, ' +
|
|
39
|
+
'then re-run validate.',
|
|
40
|
+
});
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
// A genuine module-load failure is a precondition, not a finding.
|
|
44
|
+
throw preconditionError(`Could not load the integration definition for "${integration.slug}": ` +
|
|
45
|
+
result.message, 'Ensure the entry module and its installed dependencies load under ' +
|
|
46
|
+
'Node, then re-run validate.');
|
|
47
|
+
}
|
|
48
|
+
// Cross-checks only mean something with more than one definition in hand —
|
|
49
|
+
// which is exactly what this verb could not see before, since ekanos.json
|
|
50
|
+
// held a single { slug, entry } and a second integration was invisible.
|
|
51
|
+
findings.push(...collectCollisionFindings(definitions));
|
|
52
|
+
findings.push(...collectProjectFindings(loaded.projectDir));
|
|
53
|
+
const errorCount = findings.filter((f) => f.severity === 'error').length;
|
|
54
|
+
const data = {
|
|
55
|
+
slug: loaded.primary.slug,
|
|
56
|
+
integrations: loaded.integrations.map((i) => ({
|
|
57
|
+
slug: i.slug,
|
|
58
|
+
entry: i.entry,
|
|
59
|
+
})),
|
|
60
|
+
findings,
|
|
61
|
+
};
|
|
62
|
+
if (errorCount > 0) {
|
|
63
|
+
return ctx.fail(validationError(`${errorCount} validation finding${errorCount === 1 ? '' : 's'}.`, 'Resolve the error-severity findings in data.findings, then re-run ' +
|
|
64
|
+
'"ekanos validate".'), data);
|
|
65
|
+
}
|
|
66
|
+
return ctx.succeed(data, `validate: OK — no findings for ${slugs}.`);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* `ekanos.json` and the definition each carry a slug, and nothing compared
|
|
70
|
+
* them: a project could declare `something-else` while the definition said
|
|
71
|
+
* `repo-activity` and validate would report `{ ok: true, findings: [] }`.
|
|
72
|
+
*
|
|
73
|
+
* That is the identifier every surface addresses the integration by — the
|
|
74
|
+
* harness route, the product slug, the widget id prefix, the MCP server — so
|
|
75
|
+
* two sources of truth disagreeing is not a style question. It is an error,
|
|
76
|
+
* not a warning, for the same reason a silent default is worse than a loud
|
|
77
|
+
* one: the failure it causes shows up somewhere else entirely.
|
|
78
|
+
*/
|
|
79
|
+
function collectSlugAgreementFindings(integration, definition) {
|
|
80
|
+
const declared = definition.slug;
|
|
81
|
+
if (typeof declared !== 'string' || declared === integration.slug)
|
|
82
|
+
return [];
|
|
83
|
+
return [
|
|
84
|
+
{
|
|
85
|
+
check: 'project.slug-agreement',
|
|
86
|
+
severity: 'error',
|
|
87
|
+
file: integration.entryPath,
|
|
88
|
+
message: `ekanos.json declares slug "${integration.slug}" for this entry, but ` +
|
|
89
|
+
`the definition says "${declared}". The slug addresses the ` +
|
|
90
|
+
'integration everywhere — its harness route, its product record, its ' +
|
|
91
|
+
'widget ids — so the two must agree.',
|
|
92
|
+
hint: `Change one to match the other: either set "slug": "${declared}" in ` +
|
|
93
|
+
`ekanos.json, or pass slug: '${integration.slug}' to ` +
|
|
94
|
+
'defineIntegration().',
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=validate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate.js","sourceRoot":"","sources":["../../src/commands/validate.ts"],"names":[],"mappings":"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","sourcesContent":["import type {\n DefinitionCollisionInput,\n Finding,\n} from '@ekanos/integration-schema';\nimport {\n collectCollisionFindings,\n collectDefinitionFindings,\n} from '@ekanos/integration-schema';\n\nimport type { CliContext } from '../context';\nimport { preconditionError, validationError } from '../errors';\nimport type { ExitCode } from '../exit-codes';\nimport { loadDefinition } from '../load-definition';\nimport { loadProject } from '../project';\nimport { collectProjectFindings } from '../project-checks';\n\nexport interface ValidateArgs {\n /** Project directory (contains ekanos.json). Defaults to cwd. */\n dir: string;\n}\n\n/**\n * `validate` — parse the declaration with the REAL zod schemas from\n * `@ekanos/integration-schema` (never a reimplementation), gather structured\n * findings, and add project-level integrity checks (the vitest inline-SDK\n * check). Exit 0 with `findings: []` when clean; exit 3 when any error-severity\n * finding is present, with the findings carried in the envelope's `data`.\n */\nexport async function runValidate(\n ctx: CliContext,\n args: ValidateArgs,\n): Promise<ExitCode> {\n const loaded = loadProject(args.dir);\n const slugs = loaded.integrations.map((i) => i.slug).join(', ');\n ctx.log(\n `Validating ${loaded.integrations.length} integration` +\n `${loaded.integrations.length === 1 ? '' : 's'} (${slugs})…`,\n );\n\n const findings: Finding[] = [];\n const definitions: DefinitionCollisionInput[] = [];\n\n for (const integration of loaded.integrations) {\n const result = await loadDefinition(\n integration.entryPath,\n loaded.projectDir,\n );\n\n if (result.ok) {\n findings.push(\n ...collectDefinitionFindings(result.definition, {\n file: integration.entryPath,\n }),\n );\n findings.push(\n ...collectSlugAgreementFindings(\n integration,\n result.definition as { slug?: unknown },\n ),\n );\n definitions.push(result.definition as DefinitionCollisionInput);\n continue;\n }\n\n if (result.kind === 'rejected') {\n // The module loaded but the SDK/schema rejected the definition at import\n // time — surface it as a validation finding rather than a crash.\n findings.push({\n check: 'definition.load',\n severity: 'error',\n file: integration.entryPath,\n message: result.message,\n hint:\n 'Fix the integration definition so defineIntegration() accepts it, ' +\n 'then re-run validate.',\n });\n continue;\n }\n\n // A genuine module-load failure is a precondition, not a finding.\n throw preconditionError(\n `Could not load the integration definition for \"${integration.slug}\": ` +\n result.message,\n 'Ensure the entry module and its installed dependencies load under ' +\n 'Node, then re-run validate.',\n );\n }\n\n // Cross-checks only mean something with more than one definition in hand —\n // which is exactly what this verb could not see before, since ekanos.json\n // held a single { slug, entry } and a second integration was invisible.\n findings.push(...collectCollisionFindings(definitions));\n findings.push(...collectProjectFindings(loaded.projectDir));\n\n const errorCount = findings.filter((f) => f.severity === 'error').length;\n const data = {\n slug: loaded.primary.slug,\n integrations: loaded.integrations.map((i) => ({\n slug: i.slug,\n entry: i.entry,\n })),\n findings,\n };\n\n if (errorCount > 0) {\n return ctx.fail(\n validationError(\n `${errorCount} validation finding${errorCount === 1 ? '' : 's'}.`,\n 'Resolve the error-severity findings in data.findings, then re-run ' +\n '\"ekanos validate\".',\n ),\n data,\n );\n }\n\n return ctx.succeed(data, `validate: OK — no findings for ${slugs}.`);\n}\n\n/**\n * `ekanos.json` and the definition each carry a slug, and nothing compared\n * them: a project could declare `something-else` while the definition said\n * `repo-activity` and validate would report `{ ok: true, findings: [] }`.\n *\n * That is the identifier every surface addresses the integration by — the\n * harness route, the product slug, the widget id prefix, the MCP server — so\n * two sources of truth disagreeing is not a style question. It is an error,\n * not a warning, for the same reason a silent default is worse than a loud\n * one: the failure it causes shows up somewhere else entirely.\n */\nfunction collectSlugAgreementFindings(\n integration: { slug: string; entry: string; entryPath: string },\n definition: { slug?: unknown },\n): Finding[] {\n const declared = definition.slug;\n if (typeof declared !== 'string' || declared === integration.slug) return [];\n\n return [\n {\n check: 'project.slug-agreement',\n severity: 'error',\n file: integration.entryPath,\n message:\n `ekanos.json declares slug \"${integration.slug}\" for this entry, but ` +\n `the definition says \"${declared}\". The slug addresses the ` +\n 'integration everywhere — its harness route, its product record, its ' +\n 'widget ids — so the two must agree.',\n hint:\n `Change one to match the other: either set \"slug\": \"${declared}\" in ` +\n `ekanos.json, or pass slug: '${integration.slug}' to ` +\n 'defineIntegration().',\n },\n ];\n}\n"]}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CliContext } from '../context.js';
|
|
2
|
+
import type { ExitCode } from '../exit-codes.js';
|
|
3
|
+
export interface WhoamiArgs {
|
|
4
|
+
host?: string;
|
|
5
|
+
env: Record<string, string | undefined>;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* `whoami` — prove the stored session still authenticates against Fusion.
|
|
9
|
+
*
|
|
10
|
+
* The identity is resolved server-side by `/api/auth/cli-whoami`, which reads
|
|
11
|
+
* the account through RLS as the caller. Printing the locally cached email
|
|
12
|
+
* would be a cheaper lie: it would keep saying "logged in" after the account
|
|
13
|
+
* was deleted or the token revoked.
|
|
14
|
+
*
|
|
15
|
+
* Exit 4 (auth_required) when there is no session or it cannot be renewed —
|
|
16
|
+
* the code a script should branch on to decide whether to run `ekanos login`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function runWhoami(ctx: CliContext, args: WhoamiArgs): Promise<ExitCode>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { requireSession, resolveAuthEnvironment, resolveIdentity, } from '../auth/session.js';
|
|
2
|
+
/**
|
|
3
|
+
* `whoami` — prove the stored session still authenticates against Fusion.
|
|
4
|
+
*
|
|
5
|
+
* The identity is resolved server-side by `/api/auth/cli-whoami`, which reads
|
|
6
|
+
* the account through RLS as the caller. Printing the locally cached email
|
|
7
|
+
* would be a cheaper lie: it would keep saying "logged in" after the account
|
|
8
|
+
* was deleted or the token revoked.
|
|
9
|
+
*
|
|
10
|
+
* Exit 4 (auth_required) when there is no session or it cannot be renewed —
|
|
11
|
+
* the code a script should branch on to decide whether to run `ekanos login`.
|
|
12
|
+
*/
|
|
13
|
+
export async function runWhoami(ctx, args) {
|
|
14
|
+
var _a;
|
|
15
|
+
const env = resolveAuthEnvironment(ctx, args.host, args.env);
|
|
16
|
+
const stored = requireSession(env.store, env.host);
|
|
17
|
+
const { identity, session } = await resolveIdentity(env, stored);
|
|
18
|
+
return ctx.succeed({
|
|
19
|
+
host: env.host,
|
|
20
|
+
user: {
|
|
21
|
+
id: identity.id,
|
|
22
|
+
email: identity.email,
|
|
23
|
+
name: identity.name,
|
|
24
|
+
},
|
|
25
|
+
expiresAt: session.expiresAt,
|
|
26
|
+
credentialsPath: env.store.filePath,
|
|
27
|
+
}, `whoami: ${(_a = identity.email) !== null && _a !== void 0 ? _a : identity.id} on ${env.host}.`);
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=whoami.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"whoami.js","sourceRoot":"","sources":["../../src/commands/whoami.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,sBAAsB,EACtB,eAAe,GAChB,MAAM,iBAAiB,CAAC;AASzB;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS,CAC7B,GAAe,EACf,IAAgB;;IAEhB,MAAM,GAAG,GAAG,sBAAsB,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC;IAC7D,MAAM,MAAM,GAAG,cAAc,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,CAAC,IAAI,CAAC,CAAC;IAEnD,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,eAAe,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IAEjE,OAAO,GAAG,CAAC,OAAO,CAChB;QACE,IAAI,EAAE,GAAG,CAAC,IAAI;QACd,IAAI,EAAE;YACJ,EAAE,EAAE,QAAQ,CAAC,EAAE;YACf,KAAK,EAAE,QAAQ,CAAC,KAAK;YACrB,IAAI,EAAE,QAAQ,CAAC,IAAI;SACpB;QACD,SAAS,EAAE,OAAO,CAAC,SAAS;QAC5B,eAAe,EAAE,GAAG,CAAC,KAAK,CAAC,QAAQ;KACpC,EACD,WAAW,MAAA,QAAQ,CAAC,KAAK,mCAAI,QAAQ,CAAC,EAAE,OAAO,GAAG,CAAC,IAAI,GAAG,CAC3D,CAAC;AACJ,CAAC","sourcesContent":["import {\n requireSession,\n resolveAuthEnvironment,\n resolveIdentity,\n} from '../auth/session';\nimport type { CliContext } from '../context';\nimport type { ExitCode } from '../exit-codes';\n\nexport interface WhoamiArgs {\n host?: string;\n env: Record<string, string | undefined>;\n}\n\n/**\n * `whoami` — prove the stored session still authenticates against Fusion.\n *\n * The identity is resolved server-side by `/api/auth/cli-whoami`, which reads\n * the account through RLS as the caller. Printing the locally cached email\n * would be a cheaper lie: it would keep saying \"logged in\" after the account\n * was deleted or the token revoked.\n *\n * Exit 4 (auth_required) when there is no session or it cannot be renewed —\n * the code a script should branch on to decide whether to run `ekanos login`.\n */\nexport async function runWhoami(\n ctx: CliContext,\n args: WhoamiArgs,\n): Promise<ExitCode> {\n const env = resolveAuthEnvironment(ctx, args.host, args.env);\n const stored = requireSession(env.store, env.host);\n\n const { identity, session } = await resolveIdentity(env, stored);\n\n return ctx.succeed(\n {\n host: env.host,\n user: {\n id: identity.id,\n email: identity.email,\n name: identity.name,\n },\n expiresAt: session.expiresAt,\n credentialsPath: env.store.filePath,\n },\n `whoami: ${identity.email ?? identity.id} on ${env.host}.`,\n );\n}\n"]}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shell templates ship with `@ekanos/cli`; the code they import ships with
|
|
3
|
+
* `@ekanos/harness`. The two version independently, so a partner can upgrade
|
|
4
|
+
* one without the other — and the interesting failure is the quiet one:
|
|
5
|
+
*
|
|
6
|
+
* `pnpm up @ekanos/harness` → 0.2, which moves an export.
|
|
7
|
+
* `ekanos dev` regenerates from the OLD CLI's templates and stamps "0.2"
|
|
8
|
+
* into the marker. Every later run now considers the shell current. It is
|
|
9
|
+
* not: it was generated against an obsolete contract, and what the partner
|
|
10
|
+
* sees is a module-resolution error rather than "these are incompatible".
|
|
11
|
+
*
|
|
12
|
+
* That directly attacks the promise the package boundary exists to keep — ship
|
|
13
|
+
* a harness fix as a version bump a partner picks up with `pnpm up`. So the
|
|
14
|
+
* CLI declares which `@ekanos/harness` versions ITS templates are written
|
|
15
|
+
* against, checks the installed one before generating, and names the
|
|
16
|
+
* incompatibility instead of letting a resolution error stand in for it.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* The shell contract this CLI's templates GENERATE.
|
|
20
|
+
*
|
|
21
|
+
* An integer, not a semver range. A range can only INFER compatibility from
|
|
22
|
+
* version numbers — it cannot tell a harness bugfix from a change to the shell
|
|
23
|
+
* the harness requires, so it either blocks upgrades that were fine or waves
|
|
24
|
+
* through ones that were not. The integer is declared by both sides and is
|
|
25
|
+
* authoritative.
|
|
26
|
+
*
|
|
27
|
+
* **Bump this only when the generated shell's coupling to the package
|
|
28
|
+
* changes**: an export renamed or moved between `/app` and `/routes`, the
|
|
29
|
+
* props of `RootLayout` or `IndexPage`, the server/client split, the alias
|
|
30
|
+
* specifier, which side owns `@import 'tailwindcss'`, or the set of files the
|
|
31
|
+
* shell must contain. Editing a comment in a template does not count — that is
|
|
32
|
+
* what the template fingerprint in the marker is for.
|
|
33
|
+
*/
|
|
34
|
+
export declare const SHELL_CONTRACT = 1;
|
|
35
|
+
/**
|
|
36
|
+
* The contract an installed `@ekanos/harness` declares it needs, from
|
|
37
|
+
* `{ "ekanos": { "shellContract": N } }` in its package.json.
|
|
38
|
+
*
|
|
39
|
+
* A package that declares nothing is read as contract 1 — the contract that
|
|
40
|
+
* shipped first — so the harness released before this existed keeps working.
|
|
41
|
+
* That default is deliberately silent, which is why `@ekanos/harness` asserts
|
|
42
|
+
* the field's presence in its own pack test rather than relying on it.
|
|
43
|
+
*/
|
|
44
|
+
export declare function declaredShellContract(harnessManifest: unknown): number;
|
|
45
|
+
export declare function compareVersions(a: string, b: string): number;
|
|
46
|
+
/** This CLI's own version, read from its package.json at runtime. */
|
|
47
|
+
export declare function cliVersion(): string;
|
|
48
|
+
/**
|
|
49
|
+
* Fail unless this CLI's templates generate the shell the installed harness
|
|
50
|
+
* expects.
|
|
51
|
+
*
|
|
52
|
+
* The templates ship with `@ekanos/cli`; the code they import ships with
|
|
53
|
+
* `@ekanos/harness`. The two version independently, so a partner can upgrade
|
|
54
|
+
* one without the other — and the failure that matters is the quiet one:
|
|
55
|
+
* `pnpm up @ekanos/harness` moves an export, `ekanos dev` regenerates from the
|
|
56
|
+
* OLD CLI's templates, and the partner sees a module-resolution error rather
|
|
57
|
+
* than anything naming the incompatibility.
|
|
58
|
+
*
|
|
59
|
+
* Byte-comparing generated files cannot catch it: the old templates' bytes are
|
|
60
|
+
* exactly what the old CLI intends, so nothing regenerates differently and the
|
|
61
|
+
* shell stays wired to something that moved. Only a declared contract can.
|
|
62
|
+
*
|
|
63
|
+
* Runs before anything is written, so an incompatible pair produces no shell
|
|
64
|
+
* at all — a broken shell that builds is worse than no shell.
|
|
65
|
+
*/
|
|
66
|
+
export declare function assertHarnessCompatible(params: {
|
|
67
|
+
harnessVersion: string;
|
|
68
|
+
harnessManifest: unknown;
|
|
69
|
+
installHint: (packageSpec: string) => string;
|
|
70
|
+
}): void;
|
|
71
|
+
/**
|
|
72
|
+
* A fingerprint of the template SOURCES, independent of the slug they are
|
|
73
|
+
* rendered with.
|
|
74
|
+
*
|
|
75
|
+
* The harness version alone cannot certify a shell: the templates can change
|
|
76
|
+
* while it stays put (a CLI patch release), and it can change while they stay
|
|
77
|
+
* put. Hashing the sources means any edit to what we generate invalidates
|
|
78
|
+
* every shell generated before it, including one produced by a CLI version
|
|
79
|
+
* that never shipped.
|
|
80
|
+
*/
|
|
81
|
+
export declare function templatesFingerprint(templateNames: readonly string[]): string;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { preconditionError } from './errors.js';
|
|
5
|
+
import { readTemplateSource, templatesDir } from './templates.js';
|
|
6
|
+
/**
|
|
7
|
+
* The shell templates ship with `@ekanos/cli`; the code they import ships with
|
|
8
|
+
* `@ekanos/harness`. The two version independently, so a partner can upgrade
|
|
9
|
+
* one without the other — and the interesting failure is the quiet one:
|
|
10
|
+
*
|
|
11
|
+
* `pnpm up @ekanos/harness` → 0.2, which moves an export.
|
|
12
|
+
* `ekanos dev` regenerates from the OLD CLI's templates and stamps "0.2"
|
|
13
|
+
* into the marker. Every later run now considers the shell current. It is
|
|
14
|
+
* not: it was generated against an obsolete contract, and what the partner
|
|
15
|
+
* sees is a module-resolution error rather than "these are incompatible".
|
|
16
|
+
*
|
|
17
|
+
* That directly attacks the promise the package boundary exists to keep — ship
|
|
18
|
+
* a harness fix as a version bump a partner picks up with `pnpm up`. So the
|
|
19
|
+
* CLI declares which `@ekanos/harness` versions ITS templates are written
|
|
20
|
+
* against, checks the installed one before generating, and names the
|
|
21
|
+
* incompatibility instead of letting a resolution error stand in for it.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* The shell contract this CLI's templates GENERATE.
|
|
25
|
+
*
|
|
26
|
+
* An integer, not a semver range. A range can only INFER compatibility from
|
|
27
|
+
* version numbers — it cannot tell a harness bugfix from a change to the shell
|
|
28
|
+
* the harness requires, so it either blocks upgrades that were fine or waves
|
|
29
|
+
* through ones that were not. The integer is declared by both sides and is
|
|
30
|
+
* authoritative.
|
|
31
|
+
*
|
|
32
|
+
* **Bump this only when the generated shell's coupling to the package
|
|
33
|
+
* changes**: an export renamed or moved between `/app` and `/routes`, the
|
|
34
|
+
* props of `RootLayout` or `IndexPage`, the server/client split, the alias
|
|
35
|
+
* specifier, which side owns `@import 'tailwindcss'`, or the set of files the
|
|
36
|
+
* shell must contain. Editing a comment in a template does not count — that is
|
|
37
|
+
* what the template fingerprint in the marker is for.
|
|
38
|
+
*/
|
|
39
|
+
export const SHELL_CONTRACT = 1;
|
|
40
|
+
/**
|
|
41
|
+
* The contract an installed `@ekanos/harness` declares it needs, from
|
|
42
|
+
* `{ "ekanos": { "shellContract": N } }` in its package.json.
|
|
43
|
+
*
|
|
44
|
+
* A package that declares nothing is read as contract 1 — the contract that
|
|
45
|
+
* shipped first — so the harness released before this existed keeps working.
|
|
46
|
+
* That default is deliberately silent, which is why `@ekanos/harness` asserts
|
|
47
|
+
* the field's presence in its own pack test rather than relying on it.
|
|
48
|
+
*/
|
|
49
|
+
export function declaredShellContract(harnessManifest) {
|
|
50
|
+
if (typeof harnessManifest !== 'object' || harnessManifest === null)
|
|
51
|
+
return 1;
|
|
52
|
+
const ekanos = harnessManifest.ekanos;
|
|
53
|
+
if (typeof ekanos !== 'object' || ekanos === null)
|
|
54
|
+
return 1;
|
|
55
|
+
const declared = ekanos.shellContract;
|
|
56
|
+
return Number.isInteger(declared) ? declared : 1;
|
|
57
|
+
}
|
|
58
|
+
/** `[major, minor, patch]`, with anything non-numeric treated as 0. */
|
|
59
|
+
function parseVersion(version) {
|
|
60
|
+
var _a, _b, _c;
|
|
61
|
+
const core = version.split('+')[0].split('-')[0];
|
|
62
|
+
const parts = core.split('.').map((part) => Number.parseInt(part, 10));
|
|
63
|
+
return [(_a = parts[0]) !== null && _a !== void 0 ? _a : 0, (_b = parts[1]) !== null && _b !== void 0 ? _b : 0, (_c = parts[2]) !== null && _c !== void 0 ? _c : 0];
|
|
64
|
+
}
|
|
65
|
+
export function compareVersions(a, b) {
|
|
66
|
+
const left = parseVersion(a);
|
|
67
|
+
const right = parseVersion(b);
|
|
68
|
+
for (let i = 0; i < 3; i++) {
|
|
69
|
+
if (left[i] !== right[i])
|
|
70
|
+
return left[i] < right[i] ? -1 : 1;
|
|
71
|
+
}
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
/** This CLI's own version, read from its package.json at runtime. */
|
|
75
|
+
export function cliVersion() {
|
|
76
|
+
const manifestPath = path.join(templatesDir(), '..', 'package.json');
|
|
77
|
+
const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
78
|
+
const version = typeof parsed === 'object' && parsed !== null
|
|
79
|
+
? parsed.version
|
|
80
|
+
: undefined;
|
|
81
|
+
return typeof version === 'string' ? version : '0.0.0';
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Fail unless this CLI's templates generate the shell the installed harness
|
|
85
|
+
* expects.
|
|
86
|
+
*
|
|
87
|
+
* The templates ship with `@ekanos/cli`; the code they import ships with
|
|
88
|
+
* `@ekanos/harness`. The two version independently, so a partner can upgrade
|
|
89
|
+
* one without the other — and the failure that matters is the quiet one:
|
|
90
|
+
* `pnpm up @ekanos/harness` moves an export, `ekanos dev` regenerates from the
|
|
91
|
+
* OLD CLI's templates, and the partner sees a module-resolution error rather
|
|
92
|
+
* than anything naming the incompatibility.
|
|
93
|
+
*
|
|
94
|
+
* Byte-comparing generated files cannot catch it: the old templates' bytes are
|
|
95
|
+
* exactly what the old CLI intends, so nothing regenerates differently and the
|
|
96
|
+
* shell stays wired to something that moved. Only a declared contract can.
|
|
97
|
+
*
|
|
98
|
+
* Runs before anything is written, so an incompatible pair produces no shell
|
|
99
|
+
* at all — a broken shell that builds is worse than no shell.
|
|
100
|
+
*/
|
|
101
|
+
export function assertHarnessCompatible(params) {
|
|
102
|
+
const required = declaredShellContract(params.harnessManifest);
|
|
103
|
+
if (required === SHELL_CONTRACT)
|
|
104
|
+
return;
|
|
105
|
+
if (required > SHELL_CONTRACT) {
|
|
106
|
+
throw preconditionError(`@ekanos/cli ${cliVersion()} is too old for @ekanos/harness ` +
|
|
107
|
+
`${params.harnessVersion}: that harness needs shell contract ` +
|
|
108
|
+
`${required}, and this CLI generates ${SHELL_CONTRACT}.`, `Upgrade the CLI: ${params.installHint('@ekanos/cli@latest')}. Then ` +
|
|
109
|
+
're-run "ekanos dev" and the shell is regenerated for you.');
|
|
110
|
+
}
|
|
111
|
+
throw preconditionError(`@ekanos/harness ${params.harnessVersion} is too old for @ekanos/cli ` +
|
|
112
|
+
`${cliVersion()}: this CLI generates shell contract ${SHELL_CONTRACT}, ` +
|
|
113
|
+
`and that harness needs ${required}.`, `Upgrade it: ${params.installHint('@ekanos/harness@latest')}.`);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* A fingerprint of the template SOURCES, independent of the slug they are
|
|
117
|
+
* rendered with.
|
|
118
|
+
*
|
|
119
|
+
* The harness version alone cannot certify a shell: the templates can change
|
|
120
|
+
* while it stays put (a CLI patch release), and it can change while they stay
|
|
121
|
+
* put. Hashing the sources means any edit to what we generate invalidates
|
|
122
|
+
* every shell generated before it, including one produced by a CLI version
|
|
123
|
+
* that never shipped.
|
|
124
|
+
*/
|
|
125
|
+
export function templatesFingerprint(templateNames) {
|
|
126
|
+
const hash = createHash('sha256');
|
|
127
|
+
for (const name of [...templateNames].sort()) {
|
|
128
|
+
hash.update(name);
|
|
129
|
+
hash.update('\0');
|
|
130
|
+
hash.update(readTemplateSource(name));
|
|
131
|
+
hash.update('\0');
|
|
132
|
+
}
|
|
133
|
+
return hash.digest('hex').slice(0, 16);
|
|
134
|
+
}
|
|
135
|
+
//# sourceMappingURL=compatibility.js.map
|
|
@@ -0,0 +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"]}
|