@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.
Files changed (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +326 -0
  3. package/dist/auth/credential-store.d.ts +96 -0
  4. package/dist/auth/credential-store.js +274 -0
  5. package/dist/auth/credential-store.js.map +1 -0
  6. package/dist/auth/device-flow.d.ts +57 -0
  7. package/dist/auth/device-flow.js +201 -0
  8. package/dist/auth/device-flow.js.map +1 -0
  9. package/dist/auth/fusion-api.d.ts +48 -0
  10. package/dist/auth/fusion-api.js +310 -0
  11. package/dist/auth/fusion-api.js.map +1 -0
  12. package/dist/auth/session.d.ts +57 -0
  13. package/dist/auth/session.js +114 -0
  14. package/dist/auth/session.js.map +1 -0
  15. package/dist/bin.d.ts +2 -0
  16. package/dist/bin.js +20 -0
  17. package/dist/bin.js.map +1 -0
  18. package/dist/commands/dev.d.ts +36 -0
  19. package/dist/commands/dev.js +207 -0
  20. package/dist/commands/dev.js.map +1 -0
  21. package/dist/commands/init.d.ts +30 -0
  22. package/dist/commands/init.js +178 -0
  23. package/dist/commands/init.js.map +1 -0
  24. package/dist/commands/login.d.ts +20 -0
  25. package/dist/commands/login.js +92 -0
  26. package/dist/commands/login.js.map +1 -0
  27. package/dist/commands/logout.d.ts +20 -0
  28. package/dist/commands/logout.js +75 -0
  29. package/dist/commands/logout.js.map +1 -0
  30. package/dist/commands/test.d.ts +16 -0
  31. package/dist/commands/test.js +54 -0
  32. package/dist/commands/test.js.map +1 -0
  33. package/dist/commands/validate.d.ts +14 -0
  34. package/dist/commands/validate.js +98 -0
  35. package/dist/commands/validate.js.map +1 -0
  36. package/dist/commands/whoami.d.ts +18 -0
  37. package/dist/commands/whoami.js +29 -0
  38. package/dist/commands/whoami.js.map +1 -0
  39. package/dist/compatibility.d.ts +81 -0
  40. package/dist/compatibility.js +135 -0
  41. package/dist/compatibility.js.map +1 -0
  42. package/dist/context.d.ts +81 -0
  43. package/dist/context.js +125 -0
  44. package/dist/context.js.map +1 -0
  45. package/dist/errors.d.ts +41 -0
  46. package/dist/errors.js +111 -0
  47. package/dist/errors.js.map +1 -0
  48. package/dist/exit-codes.d.ts +52 -0
  49. package/dist/exit-codes.js +51 -0
  50. package/dist/exit-codes.js.map +1 -0
  51. package/dist/harness-scaffold.d.ts +212 -0
  52. package/dist/harness-scaffold.js +434 -0
  53. package/dist/harness-scaffold.js.map +1 -0
  54. package/dist/index.d.ts +17 -0
  55. package/dist/index.js +262 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/load-definition.d.ts +20 -0
  58. package/dist/load-definition.js +126 -0
  59. package/dist/load-definition.js.map +1 -0
  60. package/dist/merge-manifest.d.ts +44 -0
  61. package/dist/merge-manifest.js +124 -0
  62. package/dist/merge-manifest.js.map +1 -0
  63. package/dist/package-manager.d.ts +25 -0
  64. package/dist/package-manager.js +57 -0
  65. package/dist/package-manager.js.map +1 -0
  66. package/dist/parse-argv.d.ts +27 -0
  67. package/dist/parse-argv.js +74 -0
  68. package/dist/parse-argv.js.map +1 -0
  69. package/dist/project-checks.d.ts +18 -0
  70. package/dist/project-checks.js +77 -0
  71. package/dist/project-checks.js.map +1 -0
  72. package/dist/project.d.ts +140 -0
  73. package/dist/project.js +228 -0
  74. package/dist/project.js.map +1 -0
  75. package/dist/safe-fs.d.ts +55 -0
  76. package/dist/safe-fs.js +209 -0
  77. package/dist/safe-fs.js.map +1 -0
  78. package/dist/templates.d.ts +23 -0
  79. package/dist/templates.js +49 -0
  80. package/dist/templates.js.map +1 -0
  81. package/package.json +62 -0
  82. package/templates/ekanos.json.tmpl +4 -0
  83. package/templates/gitignore.tmpl +12 -0
  84. package/templates/harness/app/[slug]/activation/page.tsx.tmpl +1 -0
  85. package/templates/harness/app/[slug]/layout.tsx.tmpl +1 -0
  86. package/templates/harness/app/[slug]/tile/page.tsx.tmpl +1 -0
  87. package/templates/harness/app/[slug]/triggers/page.tsx.tmpl +1 -0
  88. package/templates/harness/app/[slug]/widgets/[widgetId]/page.tsx.tmpl +1 -0
  89. package/templates/harness/app/[slug]/widgets/page.tsx.tmpl +1 -0
  90. package/templates/harness/app/harness-shell.tsx.tmpl +9 -0
  91. package/templates/harness/app/layout.tsx.tmpl +4 -0
  92. package/templates/harness/app/page.tsx.tmpl +7 -0
  93. package/templates/harness/next.config.mjs.tmpl +46 -0
  94. package/templates/harness/package.json.tmpl +11 -0
  95. package/templates/harness/postcss.config.mjs.tmpl +6 -0
  96. package/templates/harness/styles/globals.css.tmpl +33 -0
  97. package/templates/harness/tsconfig.json.tmpl +31 -0
  98. package/templates/harness/types/team-accounts.d.ts.tmpl +16 -0
  99. package/templates/harness.config.entry.ts.tmpl +65 -0
  100. package/templates/harness.config.ts.tmpl +58 -0
  101. package/templates/package.json.tmpl +28 -0
  102. package/templates/src/integration.test.ts.tmpl +24 -0
  103. package/templates/src/integration.ts.tmpl +83 -0
  104. package/templates/tsconfig.json.tmpl +19 -0
  105. package/templates/vitest.config.ts.tmpl +32 -0
@@ -0,0 +1,212 @@
1
+ /** The dependency that owns every line of harness behaviour. */
2
+ export declare const HARNESS_PACKAGE = "@ekanos/harness";
3
+ /** Where the generated shell lives, relative to the project root. */
4
+ export declare const HARNESS_DIR: string;
5
+ /** The single `.gitignore` line that covers the whole generated tree. */
6
+ export declare const GITIGNORE_ENTRY = ".ekanos/";
7
+ /**
8
+ * The staleness marker, inside the generated tree so deleting the tree also
9
+ * deletes the marker. Holds the `@ekanos/harness` version that generated the
10
+ * shell — a version bump means the shell may be wired against an older export
11
+ * surface, so it is regenerated.
12
+ */
13
+ export declare const VERSION_MARKER = ".ekanos-version";
14
+ /** The partner-owned registry, at the PROJECT ROOT. Never regenerated. */
15
+ export declare const HARNESS_CONFIG_FILENAME = "harness.config.ts";
16
+ /**
17
+ * Every file in the generated shell: bundled template → path inside
18
+ * `.ekanos/harness/`. The nine route files are mechanically derivable from the
19
+ * export map alone — which is the test of whether the package boundary is in
20
+ * the right place. If a shell file ever needs a judgment call, the boundary
21
+ * has moved, not the template.
22
+ *
23
+ * `app/harness-shell.tsx` exists because a `HarnessIntegration` holds React
24
+ * component references and MCP `run` functions. Next cannot serialize those
25
+ * across a server→client boundary, so the `'use client'` mark has to sit ABOVE
26
+ * the point where the registry is injected — hence a client shell of its own
27
+ * rather than a client `RootLayout` called from a server layout. Keeping
28
+ * `app/page.tsx` a server component is what lets `/` prerender static.
29
+ */
30
+ export declare const SHELL_FILES: readonly {
31
+ template: string;
32
+ dest: string;
33
+ }[];
34
+ /**
35
+ * Packages the shell needs to resolve upward out of the partner's own
36
+ * `node_modules`. The shell declares no dependencies of its own by design, so
37
+ * a missing one shows up as an opaque Next resolution error at dev-server
38
+ * start; checking here turns that into one precondition with one fix.
39
+ */
40
+ export declare const REQUIRED_DEPENDENCIES: readonly ["@ekanos/harness", "next", "react", "react-dom", "tailwindcss", "@tailwindcss/postcss"];
41
+ export interface WrittenFiles {
42
+ created: string[];
43
+ updated: string[];
44
+ unchanged: string[];
45
+ }
46
+ /**
47
+ * Read the installed `@ekanos/harness` version out of the partner's
48
+ * `node_modules`. Resolved by path rather than `require.resolve` on purpose:
49
+ * the CLI runs from its own install and must read the version of the copy in
50
+ * THEIR project, not one hoisted next to itself.
51
+ */
52
+ export interface InstalledHarness {
53
+ version: string;
54
+ /** The raw package.json, so compatibility can read its `ekanos` block. */
55
+ manifest: unknown;
56
+ }
57
+ export declare function readInstalledHarness(projectDir: string): InstalledHarness | null;
58
+ /** Just the installed version, for callers that need nothing else. */
59
+ export declare function readInstalledHarnessVersion(projectDir: string): string | null;
60
+ /** Which of `REQUIRED_DEPENDENCIES` are not installed in the project root. */
61
+ export declare function missingDependencies(projectDir: string): string[];
62
+ /**
63
+ * What generated an existing shell.
64
+ *
65
+ * All three fields matter, and recording only `harness` was a real hole: the
66
+ * templates ship with the CLI, so a shell can be stale because the CLI changed
67
+ * while the harness did not. `templates` is the authoritative one — it is a
68
+ * hash of the template sources, so it moves whenever what we generate moves,
69
+ * including for a CLI build that never shipped a version bump.
70
+ */
71
+ export interface ShellMarker {
72
+ harness: string;
73
+ /** null for a legacy bare-version marker written before this was recorded. */
74
+ cli: string | null;
75
+ templates: string | null;
76
+ }
77
+ /**
78
+ * Three outcomes, not two, because "no marker" and "unreadable marker" call
79
+ * for opposite responses.
80
+ *
81
+ * A marker's PRESENCE is itself evidence the tree is ours — a squatter does
82
+ * not write `.ekanos-version` — and the likeliest cause of an unreadable one
83
+ * is our own interrupted write. Everything under `.ekanos/harness/` is
84
+ * CLI-owned, gitignored and reproducible from the templates, so regenerating
85
+ * destroys nothing: there is no value at risk to justify charging a partner a
86
+ * confusing `--force` to recover from our own bad write.
87
+ *
88
+ * An ABSENT marker over a non-empty tree is the opposite: nothing says the
89
+ * files are ours, so we refuse. That is the L1 rule and it stays.
90
+ */
91
+ export type MarkerState = {
92
+ kind: 'missing';
93
+ } | {
94
+ kind: 'corrupt';
95
+ } | {
96
+ kind: 'ok';
97
+ marker: ShellMarker;
98
+ };
99
+ export declare function readShellMarkerState(harnessDir: string): MarkerState;
100
+ /** The parsed marker, or null when it is absent OR unreadable. */
101
+ export declare function readShellMarker(harnessDir: string): ShellMarker | null;
102
+ /** The `@ekanos/harness` version an existing shell was generated against. */
103
+ export declare function readShellVersion(harnessDir: string): string | null;
104
+ /**
105
+ * Write a generated file, but ONLY when its bytes would change.
106
+ *
107
+ * This is what makes a re-run cheap and a watcher quiet: an unchanged file is
108
+ * not rewritten, so its mtime does not move and Next's dev server does not
109
+ * rebuild. It also means a hand-edit inside the CLI-owned tree is silently
110
+ * corrected on the next run, which is the intended contract for `.ekanos/`.
111
+ */
112
+ export declare function writeIfChanged(root: string, destPath: string, contents: string, into: WrittenFiles, label: string, force: boolean): void;
113
+ export interface ScaffoldShellArgs {
114
+ projectDir: string;
115
+ slug: string;
116
+ harnessVersion: string;
117
+ /** Rewrite every file even when its bytes already match. */
118
+ force: boolean;
119
+ /** Extra Tailwind @source globs from ekanos.json, already validated. */
120
+ sourceGlobs?: readonly string[];
121
+ }
122
+ export interface ScaffoldShellResult extends WrittenFiles {
123
+ harnessDir: string;
124
+ /** The version the shell carried before this run — null on a first run. */
125
+ previousVersion: string | null;
126
+ /** What generated the previous shell, or null on a first run. */
127
+ previousMarker: ShellMarker | null;
128
+ /** True when anything that generated the shell has since changed. */
129
+ stale: boolean;
130
+ /**
131
+ * Which of harness / cli / templates moved. Empty on a first run; this is
132
+ * what lets `dev` say WHY it regenerated rather than just that it did.
133
+ */
134
+ staleReasons: string[];
135
+ }
136
+ /**
137
+ * Generate `.ekanos/harness/**` for `harnessVersion`. Every file here is
138
+ * CLI-owned and gitignored, so regeneration needs no permission and asks no
139
+ * question — the partner owns `harness.config.ts` and their integration
140
+ * source, and nothing else.
141
+ */
142
+ export declare function scaffoldShell(args: ScaffoldShellArgs): ScaffoldShellResult;
143
+ /**
144
+ * Establish that `.ekanos/harness` is a tree WE generated before writing a
145
+ * single byte into it, and return the version marker it carried.
146
+ *
147
+ * Path location alone is not ownership. A directory that exists but carries no
148
+ * `.ekanos-version` was put there by something else — a partner, another tool,
149
+ * a symlink — and clobbering it because it happens to sit at the path we like
150
+ * is the same class of mistake as following a symlink. An empty directory is
151
+ * fine (that is just `mkdir -p` having run), and `--force` is the explicit
152
+ * override.
153
+ *
154
+ * Runs BEFORE `bootstrapHarnessConfig` in `dev`, so a shell that cannot be
155
+ * written leaves the project completely untouched rather than half-scaffolded.
156
+ */
157
+ export declare function assertShellDirIsOurs(harnessDir: string, force: boolean): string | null;
158
+ /**
159
+ * The module specifier `harness.config.ts` uses to import the partner's
160
+ * integration: the `entry` from ekanos.json, made relative and extension-free.
161
+ * "src/integration.ts" → "src/integration".
162
+ */
163
+ export declare function entryImportSpecifier(entry: string): string;
164
+ /**
165
+ * The same specifier as a JavaScript string LITERAL, ready to drop into a
166
+ * generated `import` statement.
167
+ *
168
+ * `entry` is attacker-influenced data from `ekanos.json`, and a POSIX filename
169
+ * may legally contain a quote or a newline. Interpolating it raw would let a
170
+ * crafted-but-valid path close the string and append statements to the module
171
+ * we generate. `JSON.stringify` produces a correctly escaped literal, which is
172
+ * the actual fix; the explicit rejection below is a second line that also
173
+ * gives the partner a comprehensible error instead of a bizarre filename
174
+ * silently appearing, escaped, in their config.
175
+ *
176
+ * This does not cross a trust boundary on its own — a partner's own code runs
177
+ * unsandboxed anyway — but "the templates cannot be injected into" is a claim
178
+ * we make, and it should be true.
179
+ */
180
+ export declare function entryImportLiteral(entry: string): string;
181
+ export interface BootstrapConfigResult {
182
+ /** True only when this run created the file. */
183
+ bootstrapped: boolean;
184
+ configPath: string;
185
+ }
186
+ /**
187
+ * Write a starter `harness.config.ts` at the project root — ONCE.
188
+ *
189
+ * An existing file is left strictly alone, byte for byte, and no flag changes
190
+ * that: it is the one harness file the partner owns, it is git-tracked, and
191
+ * regenerating it would throw away their fixtures. `--force` regenerates the
192
+ * shell, never this.
193
+ */
194
+ export declare function bootstrapHarnessConfig(projectDir: string, integrations: readonly {
195
+ slug: string;
196
+ entry: string;
197
+ }[]): BootstrapConfigResult;
198
+ /**
199
+ * "acme-crm" → "acmeCrm". A slug is kebab-case by schema, so this only has to
200
+ * handle hyphens, and a leading digit is impossible.
201
+ */
202
+ export declare function identifierForSlug(slug: string): string;
203
+ /**
204
+ * Add `.ekanos/` to the project `.gitignore`, exactly once.
205
+ *
206
+ * Matching is line-wise over the spellings git treats as the same rule
207
+ * (`.ekanos`, `.ekanos/`, `/.ekanos/`), so re-running never appends a
208
+ * duplicate — the thing that makes a scaffold verb safe to run in a loop.
209
+ * A negation (`!.ekanos/`) is deliberately NOT treated as a match: the
210
+ * partner meant to un-ignore it, and we do not fight that silently.
211
+ */
212
+ export declare function ensureGitignoreEntry(projectDir: string): boolean;
@@ -0,0 +1,434 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { cliVersion, templatesFingerprint } from './compatibility.js';
4
+ import { invalidStateError, preconditionError } from './errors.js';
5
+ import { appendFileNoFollow, classifyPath, readFileNoFollow, writeFileNoFollow, } from './safe-fs.js';
6
+ import { readTemplate } from './templates.js';
7
+ /** The dependency that owns every line of harness behaviour. */
8
+ export const HARNESS_PACKAGE = '@ekanos/harness';
9
+ /** Where the generated shell lives, relative to the project root. */
10
+ export const HARNESS_DIR = path.join('.ekanos', 'harness');
11
+ /** The single `.gitignore` line that covers the whole generated tree. */
12
+ export const GITIGNORE_ENTRY = '.ekanos/';
13
+ /**
14
+ * The staleness marker, inside the generated tree so deleting the tree also
15
+ * deletes the marker. Holds the `@ekanos/harness` version that generated the
16
+ * shell — a version bump means the shell may be wired against an older export
17
+ * surface, so it is regenerated.
18
+ */
19
+ export const VERSION_MARKER = '.ekanos-version';
20
+ /** The partner-owned registry, at the PROJECT ROOT. Never regenerated. */
21
+ export const HARNESS_CONFIG_FILENAME = 'harness.config.ts';
22
+ /**
23
+ * Every file in the generated shell: bundled template → path inside
24
+ * `.ekanos/harness/`. The nine route files are mechanically derivable from the
25
+ * export map alone — which is the test of whether the package boundary is in
26
+ * the right place. If a shell file ever needs a judgment call, the boundary
27
+ * has moved, not the template.
28
+ *
29
+ * `app/harness-shell.tsx` exists because a `HarnessIntegration` holds React
30
+ * component references and MCP `run` functions. Next cannot serialize those
31
+ * across a server→client boundary, so the `'use client'` mark has to sit ABOVE
32
+ * the point where the registry is injected — hence a client shell of its own
33
+ * rather than a client `RootLayout` called from a server layout. Keeping
34
+ * `app/page.tsx` a server component is what lets `/` prerender static.
35
+ */
36
+ export const SHELL_FILES = [
37
+ { template: 'harness/app/layout.tsx.tmpl', dest: 'app/layout.tsx' },
38
+ {
39
+ template: 'harness/app/harness-shell.tsx.tmpl',
40
+ dest: 'app/harness-shell.tsx',
41
+ },
42
+ { template: 'harness/app/page.tsx.tmpl', dest: 'app/page.tsx' },
43
+ {
44
+ template: 'harness/app/[slug]/layout.tsx.tmpl',
45
+ dest: 'app/[slug]/layout.tsx',
46
+ },
47
+ {
48
+ template: 'harness/app/[slug]/widgets/page.tsx.tmpl',
49
+ dest: 'app/[slug]/widgets/page.tsx',
50
+ },
51
+ {
52
+ template: 'harness/app/[slug]/widgets/[widgetId]/page.tsx.tmpl',
53
+ dest: 'app/[slug]/widgets/[widgetId]/page.tsx',
54
+ },
55
+ {
56
+ template: 'harness/app/[slug]/tile/page.tsx.tmpl',
57
+ dest: 'app/[slug]/tile/page.tsx',
58
+ },
59
+ {
60
+ template: 'harness/app/[slug]/activation/page.tsx.tmpl',
61
+ dest: 'app/[slug]/activation/page.tsx',
62
+ },
63
+ {
64
+ template: 'harness/app/[slug]/triggers/page.tsx.tmpl',
65
+ dest: 'app/[slug]/triggers/page.tsx',
66
+ },
67
+ { template: 'harness/next.config.mjs.tmpl', dest: 'next.config.mjs' },
68
+ { template: 'harness/postcss.config.mjs.tmpl', dest: 'postcss.config.mjs' },
69
+ { template: 'harness/tsconfig.json.tmpl', dest: 'tsconfig.json' },
70
+ { template: 'harness/styles/globals.css.tmpl', dest: 'styles/globals.css' },
71
+ { template: 'harness/package.json.tmpl', dest: 'package.json' },
72
+ {
73
+ template: 'harness/types/team-accounts.d.ts.tmpl',
74
+ dest: 'types/team-accounts.d.ts',
75
+ },
76
+ ];
77
+ /**
78
+ * Packages the shell needs to resolve upward out of the partner's own
79
+ * `node_modules`. The shell declares no dependencies of its own by design, so
80
+ * a missing one shows up as an opaque Next resolution error at dev-server
81
+ * start; checking here turns that into one precondition with one fix.
82
+ */
83
+ export const REQUIRED_DEPENDENCIES = [
84
+ HARNESS_PACKAGE,
85
+ 'next',
86
+ 'react',
87
+ 'react-dom',
88
+ 'tailwindcss',
89
+ '@tailwindcss/postcss',
90
+ ];
91
+ export function readInstalledHarness(projectDir) {
92
+ const manifest = path.join(projectDir, 'node_modules', HARNESS_PACKAGE, 'package.json');
93
+ if (!fs.existsSync(manifest))
94
+ return null;
95
+ try {
96
+ const parsed = JSON.parse(fs.readFileSync(manifest, 'utf8'));
97
+ const version = typeof parsed === 'object' && parsed !== null
98
+ ? parsed.version
99
+ : undefined;
100
+ if (typeof version !== 'string' || version.length === 0)
101
+ return null;
102
+ return { version, manifest: parsed };
103
+ }
104
+ catch (error) {
105
+ throw preconditionError(`${HARNESS_PACKAGE}'s package.json in node_modules is unreadable: ${error instanceof Error ? error.message : String(error)}`, `Reinstall dependencies in ${projectDir} so ${HARNESS_PACKAGE} resolves.`);
106
+ }
107
+ }
108
+ /** Just the installed version, for callers that need nothing else. */
109
+ export function readInstalledHarnessVersion(projectDir) {
110
+ var _a, _b;
111
+ return (_b = (_a = readInstalledHarness(projectDir)) === null || _a === void 0 ? void 0 : _a.version) !== null && _b !== void 0 ? _b : null;
112
+ }
113
+ /** Which of `REQUIRED_DEPENDENCIES` are not installed in the project root. */
114
+ export function missingDependencies(projectDir) {
115
+ return REQUIRED_DEPENDENCIES.filter((name) => !fs.existsSync(path.join(projectDir, 'node_modules', name, 'package.json')));
116
+ }
117
+ export function readShellMarkerState(harnessDir) {
118
+ var _a;
119
+ const marker = path.join(harnessDir, VERSION_MARKER);
120
+ if (classifyPath(marker) !== 'file')
121
+ return { kind: 'missing' };
122
+ const raw = ((_a = readFileNoFollow(harnessDir, marker)) !== null && _a !== void 0 ? _a : '').trim();
123
+ if (raw.length === 0)
124
+ return { kind: 'corrupt' };
125
+ const parsed = parseMarker(raw);
126
+ return parsed === null ? { kind: 'corrupt' } : { kind: 'ok', marker: parsed };
127
+ }
128
+ function parseMarker(raw) {
129
+ // v1 markers were a bare version string. Read them rather than crashing, and
130
+ // report the unknown fields as null so the shell counts as stale and is
131
+ // regenerated once — which is the correct outcome for an upgrade.
132
+ //
133
+ // It must actually LOOK like a version: without this, any garbage that does
134
+ // not happen to start with `{` — a truncated write, a stray word — reads as
135
+ // a legacy marker named after the garbage, and the corrupt-marker path never
136
+ // fires. That is how a truncated `.ekanos-version` was silently reported as
137
+ // "@ekanos/harness truncat → 0.1.0" instead of "unreadable".
138
+ if (!raw.startsWith('{')) {
139
+ return /^\d+\.\d+/.test(raw)
140
+ ? { harness: raw, cli: null, templates: null }
141
+ : null;
142
+ }
143
+ try {
144
+ const parsed = JSON.parse(raw);
145
+ if (typeof parsed !== 'object' || parsed === null)
146
+ return null;
147
+ const record = parsed;
148
+ const harness = record.harness;
149
+ if (typeof harness !== 'string')
150
+ return null;
151
+ return {
152
+ harness,
153
+ cli: typeof record.cli === 'string' ? record.cli : null,
154
+ templates: typeof record.templates === 'string' ? record.templates : null,
155
+ };
156
+ }
157
+ catch (_a) {
158
+ return null;
159
+ }
160
+ }
161
+ /** The parsed marker, or null when it is absent OR unreadable. */
162
+ export function readShellMarker(harnessDir) {
163
+ const state = readShellMarkerState(harnessDir);
164
+ return state.kind === 'ok' ? state.marker : null;
165
+ }
166
+ /** The `@ekanos/harness` version an existing shell was generated against. */
167
+ export function readShellVersion(harnessDir) {
168
+ var _a, _b;
169
+ return (_b = (_a = readShellMarker(harnessDir)) === null || _a === void 0 ? void 0 : _a.harness) !== null && _b !== void 0 ? _b : null;
170
+ }
171
+ function serializeMarker(marker) {
172
+ return `${JSON.stringify(marker, null, 2)}\n`;
173
+ }
174
+ /**
175
+ * Write a generated file, but ONLY when its bytes would change.
176
+ *
177
+ * This is what makes a re-run cheap and a watcher quiet: an unchanged file is
178
+ * not rewritten, so its mtime does not move and Next's dev server does not
179
+ * rebuild. It also means a hand-edit inside the CLI-owned tree is silently
180
+ * corrected on the next run, which is the intended contract for `.ekanos/`.
181
+ */
182
+ export function writeIfChanged(root, destPath, contents, into, label, force) {
183
+ // `classifyPath` uses lstat, so a symlink here is reported as a symlink
184
+ // rather than as whatever it points at — including a DANGLING one, which
185
+ // `existsSync` would call absent and a plain write would then materialize
186
+ // outside the project.
187
+ const kind = classifyPath(destPath);
188
+ const exists = kind !== 'missing';
189
+ if (!force &&
190
+ kind === 'file' &&
191
+ readFileNoFollow(root, destPath) === contents) {
192
+ into.unchanged.push(label);
193
+ return;
194
+ }
195
+ writeFileNoFollow(root, destPath, contents);
196
+ (exists ? into.updated : into.created).push(label);
197
+ }
198
+ /**
199
+ * Generate `.ekanos/harness/**` for `harnessVersion`. Every file here is
200
+ * CLI-owned and gitignored, so regeneration needs no permission and asks no
201
+ * question — the partner owns `harness.config.ts` and their integration
202
+ * source, and nothing else.
203
+ */
204
+ export function scaffoldShell(args) {
205
+ var _a, _b, _c;
206
+ const harnessDir = path.join(args.projectDir, HARNESS_DIR);
207
+ assertShellDirIsOurs(harnessDir, args.force);
208
+ const markerState = readShellMarkerState(harnessDir);
209
+ const previousMarker = markerState.kind === 'ok' ? markerState.marker : null;
210
+ const previousVersion = (_a = previousMarker === null || previousMarker === void 0 ? void 0 : previousMarker.harness) !== null && _a !== void 0 ? _a : null;
211
+ const marker = {
212
+ harness: args.harnessVersion,
213
+ cli: cliVersion(),
214
+ templates: templatesFingerprint(SHELL_FILES.map((f) => f.template)),
215
+ };
216
+ // A shell is current only if EVERYTHING that produced it is unchanged.
217
+ // Checking the harness version alone certified shells generated by an older
218
+ // CLI, against templates that had since moved, as up to date.
219
+ const staleReasons = [];
220
+ if (markerState.kind === 'corrupt') {
221
+ staleReasons.push(`${VERSION_MARKER} was unreadable, so the existing shell could not be ` +
222
+ 'certified');
223
+ }
224
+ if (previousMarker !== null) {
225
+ if (previousMarker.harness !== marker.harness) {
226
+ staleReasons.push(`@ekanos/harness ${previousMarker.harness} → ${marker.harness}`);
227
+ }
228
+ if (previousMarker.cli !== marker.cli) {
229
+ staleReasons.push(`@ekanos/cli ${(_b = previousMarker.cli) !== null && _b !== void 0 ? _b : 'unknown'} → ${marker.cli}`);
230
+ }
231
+ if (previousMarker.templates !== marker.templates) {
232
+ staleReasons.push('the shell templates changed');
233
+ }
234
+ }
235
+ const stale = staleReasons.length > 0;
236
+ const files = { created: [], updated: [], unchanged: [] };
237
+ const vars = {
238
+ SLUG: args.slug,
239
+ SOURCE_GLOBS: renderSourceGlobs((_c = args.sourceGlobs) !== null && _c !== void 0 ? _c : []),
240
+ };
241
+ for (const { template, dest } of SHELL_FILES) {
242
+ writeIfChanged(args.projectDir, path.join(harnessDir, dest), readTemplate(template, vars), files, dest, args.force);
243
+ }
244
+ writeIfChanged(args.projectDir, path.join(harnessDir, VERSION_MARKER), serializeMarker(marker), files, VERSION_MARKER, args.force);
245
+ return Object.assign(Object.assign({}, files), { harnessDir,
246
+ previousVersion,
247
+ previousMarker,
248
+ stale,
249
+ staleReasons });
250
+ }
251
+ /**
252
+ * Turn validated `sourceGlobs` into the `@source` lines the generated
253
+ * stylesheet carries, each relative to the shell's `styles/` directory.
254
+ *
255
+ * Tailwind never scans what it is not pointed at, and a glob that matches
256
+ * nothing fails silently — so an integration living outside the default
257
+ * layout renders completely unstyled with no error anywhere. That is what
258
+ * `sourceGlobs` is for, and until now it was parsed and thrown away.
259
+ */
260
+ function renderSourceGlobs(globs) {
261
+ if (globs.length === 0)
262
+ return '';
263
+ const lines = globs.map((glob) => `@source '${SHELL_TO_PROJECT}${glob}';`);
264
+ return `\n/* From "sourceGlobs" in ekanos.json. */\n${lines.join('\n')}\n`;
265
+ }
266
+ /** From `.ekanos/harness/styles/` back up to the project root. */
267
+ const SHELL_TO_PROJECT = '../../../';
268
+ /**
269
+ * Establish that `.ekanos/harness` is a tree WE generated before writing a
270
+ * single byte into it, and return the version marker it carried.
271
+ *
272
+ * Path location alone is not ownership. A directory that exists but carries no
273
+ * `.ekanos-version` was put there by something else — a partner, another tool,
274
+ * a symlink — and clobbering it because it happens to sit at the path we like
275
+ * is the same class of mistake as following a symlink. An empty directory is
276
+ * fine (that is just `mkdir -p` having run), and `--force` is the explicit
277
+ * override.
278
+ *
279
+ * Runs BEFORE `bootstrapHarnessConfig` in `dev`, so a shell that cannot be
280
+ * written leaves the project completely untouched rather than half-scaffolded.
281
+ */
282
+ export function assertShellDirIsOurs(harnessDir, force) {
283
+ const kind = classifyPath(harnessDir);
284
+ if (kind === 'symlink') {
285
+ throw invalidStateError(`${harnessDir} is a symlink, so "ekanos dev" will not write through it.`, `Delete ${harnessDir} and re-run "ekanos dev" — the shell is generated, ` +
286
+ 'so nothing is lost.');
287
+ }
288
+ if (kind !== 'missing' && kind !== 'directory') {
289
+ throw invalidStateError(`${harnessDir} exists and is not a directory.`, `Move or delete ${harnessDir}, then re-run "ekanos dev".`);
290
+ }
291
+ if (kind === 'missing')
292
+ return null;
293
+ const state = readShellMarkerState(harnessDir);
294
+ // A marker we wrote, however mangled, still identifies the tree as ours.
295
+ if (state.kind === 'ok')
296
+ return state.marker.harness;
297
+ if (state.kind === 'corrupt' || force)
298
+ return null;
299
+ if (fs.readdirSync(harnessDir).length === 0)
300
+ return null;
301
+ throw invalidStateError(`${harnessDir} already contains files but no ${VERSION_MARKER} marker, ` +
302
+ 'so it was not generated by "ekanos dev".', `Delete ${harnessDir} if you do not need it, or pass --force to ` +
303
+ 'overwrite it. Everything "ekanos dev" generates is reproducible; ' +
304
+ 'anything else in there is not.');
305
+ }
306
+ /**
307
+ * The module specifier `harness.config.ts` uses to import the partner's
308
+ * integration: the `entry` from ekanos.json, made relative and extension-free.
309
+ * "src/integration.ts" → "src/integration".
310
+ */
311
+ export function entryImportSpecifier(entry) {
312
+ const normalized = entry.split(path.sep).join('/').replace(/^\.\//, '');
313
+ return normalized.replace(/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/, '');
314
+ }
315
+ /**
316
+ * The same specifier as a JavaScript string LITERAL, ready to drop into a
317
+ * generated `import` statement.
318
+ *
319
+ * `entry` is attacker-influenced data from `ekanos.json`, and a POSIX filename
320
+ * may legally contain a quote or a newline. Interpolating it raw would let a
321
+ * crafted-but-valid path close the string and append statements to the module
322
+ * we generate. `JSON.stringify` produces a correctly escaped literal, which is
323
+ * the actual fix; the explicit rejection below is a second line that also
324
+ * gives the partner a comprehensible error instead of a bizarre filename
325
+ * silently appearing, escaped, in their config.
326
+ *
327
+ * This does not cross a trust boundary on its own — a partner's own code runs
328
+ * unsandboxed anyway — but "the templates cannot be injected into" is a claim
329
+ * we make, and it should be true.
330
+ */
331
+ export function entryImportLiteral(entry) {
332
+ const specifier = entryImportSpecifier(entry);
333
+ // eslint-disable-next-line no-control-regex
334
+ if (/["'`\\\n\r\u0000-\u001f]/.test(specifier)) {
335
+ throw preconditionError(`The "entry" in ekanos.json contains characters that cannot appear in ` +
336
+ `an import specifier: ${JSON.stringify(specifier)}.`, 'Rename the file so its path has no quotes, backslashes or control ' +
337
+ 'characters, then update "entry" in ekanos.json to match.');
338
+ }
339
+ return JSON.stringify(`./${specifier}`);
340
+ }
341
+ /**
342
+ * Write a starter `harness.config.ts` at the project root — ONCE.
343
+ *
344
+ * An existing file is left strictly alone, byte for byte, and no flag changes
345
+ * that: it is the one harness file the partner owns, it is git-tracked, and
346
+ * regenerating it would throw away their fixtures. `--force` regenerates the
347
+ * shell, never this.
348
+ */
349
+ export function bootstrapHarnessConfig(projectDir, integrations) {
350
+ const configPath = path.join(projectDir, HARNESS_CONFIG_FILENAME);
351
+ const kind = classifyPath(configPath);
352
+ // A symlink here — including a dangling one, which `existsSync` reports as
353
+ // absent — would have us write through to its target. Since this file is the
354
+ // partner's to own, the only safe answer is to touch nothing and say why.
355
+ if (kind === 'symlink') {
356
+ throw invalidStateError(`${configPath} is a symlink, so "ekanos dev" will not write through it.`, `Replace ${configPath} with a real file (or delete it to have one ` +
357
+ 'generated), then re-run "ekanos dev".');
358
+ }
359
+ if (kind !== 'missing') {
360
+ return { bootstrapped: false, configPath };
361
+ }
362
+ writeFileNoFollow(projectDir, configPath, renderHarnessConfig(integrations));
363
+ return { bootstrapped: true, configPath };
364
+ }
365
+ /**
366
+ * Render the starter registry for EVERY integration the project declares.
367
+ *
368
+ * The harness registry is an array and `ekanos.json` can now hold a list, so a
369
+ * project's second integration reaches the harness the same way its first
370
+ * does. Each entry imports the definition under a distinct local identifier
371
+ * derived from its slug, because two `integration` imports in one module do
372
+ * not compile.
373
+ */
374
+ function renderHarnessConfig(integrations) {
375
+ const imports = [];
376
+ const entries = [];
377
+ for (const integration of integrations) {
378
+ const ident = identifierForSlug(integration.slug);
379
+ imports.push(`import { integration as ${ident} } from ${entryImportLiteral(integration.entry)};`);
380
+ entries.push(readTemplate('harness.config.entry.ts.tmpl', {
381
+ IDENT: ident,
382
+ SLUG: integration.slug,
383
+ }));
384
+ }
385
+ return readTemplate('harness.config.ts.tmpl', {
386
+ INTEGRATION_IMPORTS: imports.join('\n'),
387
+ INTEGRATION_ENTRIES: entries.join(''),
388
+ });
389
+ }
390
+ /**
391
+ * "acme-crm" → "acmeCrm". A slug is kebab-case by schema, so this only has to
392
+ * handle hyphens, and a leading digit is impossible.
393
+ */
394
+ export function identifierForSlug(slug) {
395
+ const camel = slug.replace(/-([a-z0-9])/g, (_, char) => char.toUpperCase());
396
+ return /^[0-9]/.test(camel) ? `i${camel}` : camel;
397
+ }
398
+ /**
399
+ * Add `.ekanos/` to the project `.gitignore`, exactly once.
400
+ *
401
+ * Matching is line-wise over the spellings git treats as the same rule
402
+ * (`.ekanos`, `.ekanos/`, `/.ekanos/`), so re-running never appends a
403
+ * duplicate — the thing that makes a scaffold verb safe to run in a loop.
404
+ * A negation (`!.ekanos/`) is deliberately NOT treated as a match: the
405
+ * partner meant to un-ignore it, and we do not fight that silently.
406
+ */
407
+ export function ensureGitignoreEntry(projectDir) {
408
+ var _a;
409
+ const gitignorePath = path.join(projectDir, '.gitignore');
410
+ const block = `# Ekanos dev harness — generated by "ekanos dev", never edited by hand\n${GITIGNORE_ENTRY}\n`;
411
+ const kind = classifyPath(gitignorePath);
412
+ if (kind === 'symlink') {
413
+ throw invalidStateError(`${gitignorePath} is a symlink, so "ekanos dev" will not append to it.`, `Add "${GITIGNORE_ENTRY}" to your real .gitignore yourself, or replace ` +
414
+ `${gitignorePath} with a regular file, then re-run "ekanos dev".`);
415
+ }
416
+ if (kind === 'missing') {
417
+ writeFileNoFollow(projectDir, gitignorePath, block);
418
+ return true;
419
+ }
420
+ const existing = (_a = readFileNoFollow(projectDir, gitignorePath)) !== null && _a !== void 0 ? _a : '';
421
+ if (gitignoreCovers(existing))
422
+ return false;
423
+ const separator = existing.length === 0 || existing.endsWith('\n') ? '' : '\n';
424
+ appendFileNoFollow(projectDir, gitignorePath, `${separator}\n${block}`);
425
+ return true;
426
+ }
427
+ function gitignoreCovers(source) {
428
+ const equivalents = new Set(['.ekanos', '.ekanos/', '/.ekanos', '/.ekanos/']);
429
+ return source
430
+ .split('\n')
431
+ .map((line) => line.trim())
432
+ .some((line) => equivalents.has(line));
433
+ }
434
+ //# sourceMappingURL=harness-scaffold.js.map