@ekanos/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +326 -0
- package/dist/auth/credential-store.d.ts +96 -0
- package/dist/auth/credential-store.js +274 -0
- package/dist/auth/credential-store.js.map +1 -0
- package/dist/auth/device-flow.d.ts +57 -0
- package/dist/auth/device-flow.js +201 -0
- package/dist/auth/device-flow.js.map +1 -0
- package/dist/auth/fusion-api.d.ts +48 -0
- package/dist/auth/fusion-api.js +310 -0
- package/dist/auth/fusion-api.js.map +1 -0
- package/dist/auth/session.d.ts +57 -0
- package/dist/auth/session.js +114 -0
- package/dist/auth/session.js.map +1 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +20 -0
- package/dist/bin.js.map +1 -0
- package/dist/commands/dev.d.ts +36 -0
- package/dist/commands/dev.js +207 -0
- package/dist/commands/dev.js.map +1 -0
- package/dist/commands/init.d.ts +30 -0
- package/dist/commands/init.js +178 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/login.d.ts +20 -0
- package/dist/commands/login.js +92 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/logout.d.ts +20 -0
- package/dist/commands/logout.js +75 -0
- package/dist/commands/logout.js.map +1 -0
- package/dist/commands/test.d.ts +16 -0
- package/dist/commands/test.js +54 -0
- package/dist/commands/test.js.map +1 -0
- package/dist/commands/validate.d.ts +14 -0
- package/dist/commands/validate.js +98 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/commands/whoami.d.ts +18 -0
- package/dist/commands/whoami.js +29 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/compatibility.d.ts +81 -0
- package/dist/compatibility.js +135 -0
- package/dist/compatibility.js.map +1 -0
- package/dist/context.d.ts +81 -0
- package/dist/context.js +125 -0
- package/dist/context.js.map +1 -0
- package/dist/errors.d.ts +41 -0
- package/dist/errors.js +111 -0
- package/dist/errors.js.map +1 -0
- package/dist/exit-codes.d.ts +52 -0
- package/dist/exit-codes.js +51 -0
- package/dist/exit-codes.js.map +1 -0
- package/dist/harness-scaffold.d.ts +212 -0
- package/dist/harness-scaffold.js +434 -0
- package/dist/harness-scaffold.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +262 -0
- package/dist/index.js.map +1 -0
- package/dist/load-definition.d.ts +20 -0
- package/dist/load-definition.js +126 -0
- package/dist/load-definition.js.map +1 -0
- package/dist/merge-manifest.d.ts +44 -0
- package/dist/merge-manifest.js +124 -0
- package/dist/merge-manifest.js.map +1 -0
- package/dist/package-manager.d.ts +25 -0
- package/dist/package-manager.js +57 -0
- package/dist/package-manager.js.map +1 -0
- package/dist/parse-argv.d.ts +27 -0
- package/dist/parse-argv.js +74 -0
- package/dist/parse-argv.js.map +1 -0
- package/dist/project-checks.d.ts +18 -0
- package/dist/project-checks.js +77 -0
- package/dist/project-checks.js.map +1 -0
- package/dist/project.d.ts +140 -0
- package/dist/project.js +228 -0
- package/dist/project.js.map +1 -0
- package/dist/safe-fs.d.ts +55 -0
- package/dist/safe-fs.js +209 -0
- package/dist/safe-fs.js.map +1 -0
- package/dist/templates.d.ts +23 -0
- package/dist/templates.js +49 -0
- package/dist/templates.js.map +1 -0
- package/package.json +62 -0
- package/templates/ekanos.json.tmpl +4 -0
- package/templates/gitignore.tmpl +12 -0
- package/templates/harness/app/[slug]/activation/page.tsx.tmpl +1 -0
- package/templates/harness/app/[slug]/layout.tsx.tmpl +1 -0
- package/templates/harness/app/[slug]/tile/page.tsx.tmpl +1 -0
- package/templates/harness/app/[slug]/triggers/page.tsx.tmpl +1 -0
- package/templates/harness/app/[slug]/widgets/[widgetId]/page.tsx.tmpl +1 -0
- package/templates/harness/app/[slug]/widgets/page.tsx.tmpl +1 -0
- package/templates/harness/app/harness-shell.tsx.tmpl +9 -0
- package/templates/harness/app/layout.tsx.tmpl +4 -0
- package/templates/harness/app/page.tsx.tmpl +7 -0
- package/templates/harness/next.config.mjs.tmpl +46 -0
- package/templates/harness/package.json.tmpl +11 -0
- package/templates/harness/postcss.config.mjs.tmpl +6 -0
- package/templates/harness/styles/globals.css.tmpl +33 -0
- package/templates/harness/tsconfig.json.tmpl +31 -0
- package/templates/harness/types/team-accounts.d.ts.tmpl +16 -0
- package/templates/harness.config.entry.ts.tmpl +65 -0
- package/templates/harness.config.ts.tmpl +58 -0
- package/templates/package.json.tmpl +28 -0
- package/templates/src/integration.test.ts.tmpl +24 -0
- package/templates/src/integration.ts.tmpl +83 -0
- package/templates/tsconfig.json.tmpl +19 -0
- package/templates/vitest.config.ts.tmpl +32 -0
package/dist/project.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { notFoundError, preconditionError } from './errors.js';
|
|
5
|
+
const SlugSchema = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
|
6
|
+
message: 'slug must be kebab-case ([a-z0-9] segments separated by single hyphens), e.g. "acme-crm".',
|
|
7
|
+
});
|
|
8
|
+
const EntrySchema = z
|
|
9
|
+
.string()
|
|
10
|
+
.min(1, { message: 'entry must be a path to the integration module.' });
|
|
11
|
+
/**
|
|
12
|
+
* Extra Tailwind content globs, for an integration that does not live under
|
|
13
|
+
* the default layout. They are interpolated into the generated
|
|
14
|
+
* `styles/globals.css` as `@source` lines, so they are validated as
|
|
15
|
+
* DATA-IN-CSS: project-relative, no escaping the project, and none of the
|
|
16
|
+
* characters that could close the string or the surrounding comment.
|
|
17
|
+
*
|
|
18
|
+
* Being strict here is deliberate. A glob that matches nothing costs a partner
|
|
19
|
+
* a silently unstyled integration; a glob that is rejected costs them one
|
|
20
|
+
* clear error.
|
|
21
|
+
*/
|
|
22
|
+
const SOURCE_GLOB_PATTERN = /^[A-Za-z0-9_\-./*{},[\]!]+$/;
|
|
23
|
+
const SourceGlobSchema = z
|
|
24
|
+
.string()
|
|
25
|
+
.min(1, { message: 'a sourceGlobs entry must not be empty.' })
|
|
26
|
+
.max(200, { message: 'a sourceGlobs entry must be under 200 characters.' })
|
|
27
|
+
.regex(SOURCE_GLOB_PATTERN, {
|
|
28
|
+
message: 'a sourceGlobs entry may contain only letters, digits and _-./*{},[]! ' +
|
|
29
|
+
'— it is written verbatim into the generated stylesheet.',
|
|
30
|
+
})
|
|
31
|
+
.refine((glob) => !glob.startsWith('/'), {
|
|
32
|
+
message: 'a sourceGlobs entry must be relative to the project root.',
|
|
33
|
+
})
|
|
34
|
+
.refine((glob) => !glob.split('/').includes('..'), {
|
|
35
|
+
message: 'a sourceGlobs entry must not escape the project root with "..".',
|
|
36
|
+
});
|
|
37
|
+
/** One integration: the pair that addresses a definition module. */
|
|
38
|
+
const IntegrationEntrySchema = z
|
|
39
|
+
.object({ slug: SlugSchema, entry: EntrySchema })
|
|
40
|
+
.strict();
|
|
41
|
+
/**
|
|
42
|
+
* `ekanos.json` — the project contract. Written by `init`, read by `validate`,
|
|
43
|
+
* `dev` and `test`.
|
|
44
|
+
*
|
|
45
|
+
* Two shapes, because a project can hold more than one integration and the
|
|
46
|
+
* single-integration form is what `init` writes and what most projects keep:
|
|
47
|
+
*
|
|
48
|
+
* ```json
|
|
49
|
+
* { "slug": "acme-crm", "entry": "src/integration.ts" }
|
|
50
|
+
*
|
|
51
|
+
* { "integrations": [
|
|
52
|
+
* { "slug": "acme-crm", "entry": "src/crm.ts" },
|
|
53
|
+
* { "slug": "acme-billing", "entry": "src/billing.ts" }
|
|
54
|
+
* ] }
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* A second integration used to be invisible to the CLI entirely — the harness
|
|
58
|
+
* registry is an array and `collectCollisionFindings` exists precisely to
|
|
59
|
+
* cross-check several definitions against each other, but `validate` could
|
|
60
|
+
* only ever see one. Both forms normalise to the same list, so nothing
|
|
61
|
+
* downstream has to know which was written.
|
|
62
|
+
*/
|
|
63
|
+
export const EkanosProjectSchema = z
|
|
64
|
+
.object({
|
|
65
|
+
$schema: z.string().optional(),
|
|
66
|
+
slug: SlugSchema.optional(),
|
|
67
|
+
entry: EntrySchema.optional(),
|
|
68
|
+
integrations: z.array(IntegrationEntrySchema).optional(),
|
|
69
|
+
// There is deliberately no `harness` field. One existed, accepted by the
|
|
70
|
+
// schema and read by nothing — the same accept-and-ignore that made
|
|
71
|
+
// `sourceGlobs` cost a partner a silently unstyled integration. `.strict()`
|
|
72
|
+
// now rejects it by name, which is the honest answer until something
|
|
73
|
+
// actually consumes it.
|
|
74
|
+
sourceGlobs: z.array(SourceGlobSchema).optional(),
|
|
75
|
+
})
|
|
76
|
+
.strict()
|
|
77
|
+
.superRefine((value, ctx) => {
|
|
78
|
+
const hasSingle = value.slug !== undefined || value.entry !== undefined;
|
|
79
|
+
const hasList = value.integrations !== undefined;
|
|
80
|
+
if (hasSingle && hasList) {
|
|
81
|
+
ctx.addIssue({
|
|
82
|
+
code: z.ZodIssueCode.custom,
|
|
83
|
+
message: 'use either the single form ({ slug, entry }) or the list form ' +
|
|
84
|
+
'({ integrations: [...] }), not both — two places to declare the ' +
|
|
85
|
+
'same thing is how they drift apart.',
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (hasList) {
|
|
90
|
+
if (value.integrations.length === 0) {
|
|
91
|
+
ctx.addIssue({
|
|
92
|
+
code: z.ZodIssueCode.custom,
|
|
93
|
+
path: ['integrations'],
|
|
94
|
+
message: 'integrations must list at least one integration.',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
value.integrations.forEach((entry, index) => {
|
|
99
|
+
if (seen.has(entry.slug)) {
|
|
100
|
+
ctx.addIssue({
|
|
101
|
+
code: z.ZodIssueCode.custom,
|
|
102
|
+
path: ['integrations', index, 'slug'],
|
|
103
|
+
message: `duplicate slug "${entry.slug}" — every integration in a ` +
|
|
104
|
+
'project needs its own, since the slug addresses it everywhere.',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
seen.add(entry.slug);
|
|
108
|
+
});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (value.slug === undefined) {
|
|
112
|
+
ctx.addIssue({
|
|
113
|
+
code: z.ZodIssueCode.custom,
|
|
114
|
+
path: ['slug'],
|
|
115
|
+
message: 'slug is required (or use "integrations": [...]).',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (value.entry === undefined) {
|
|
119
|
+
ctx.addIssue({
|
|
120
|
+
code: z.ZodIssueCode.custom,
|
|
121
|
+
path: ['entry'],
|
|
122
|
+
message: 'entry is required (or use "integrations": [...]).',
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
/** Both `ekanos.json` shapes, flattened to the one the CLI works in. */
|
|
127
|
+
export function projectIntegrations(project) {
|
|
128
|
+
if (project.integrations !== undefined)
|
|
129
|
+
return [...project.integrations];
|
|
130
|
+
return [{ slug: project.slug, entry: project.entry }];
|
|
131
|
+
}
|
|
132
|
+
export const EKANOS_CONFIG_FILENAME = 'ekanos.json';
|
|
133
|
+
/**
|
|
134
|
+
* Read and validate ekanos.json from a directory. Missing file → not-found
|
|
135
|
+
* (exit 6); malformed JSON or a schema violation → precondition failed
|
|
136
|
+
* (exit 9), because a project the CLI cannot even read is a precondition for
|
|
137
|
+
* every verb, not a definition-level validation finding.
|
|
138
|
+
*/
|
|
139
|
+
export function loadProject(dir) {
|
|
140
|
+
var _a;
|
|
141
|
+
const projectDir = path.resolve(dir);
|
|
142
|
+
const configPath = path.join(projectDir, EKANOS_CONFIG_FILENAME);
|
|
143
|
+
if (!fs.existsSync(configPath)) {
|
|
144
|
+
throw notFoundError(`No ${EKANOS_CONFIG_FILENAME} found in ${projectDir}.`, `Run "ekanos init" here first, or change into the project directory ` +
|
|
145
|
+
`that contains ${EKANOS_CONFIG_FILENAME}.`);
|
|
146
|
+
}
|
|
147
|
+
let raw;
|
|
148
|
+
try {
|
|
149
|
+
raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
throw preconditionError(`${EKANOS_CONFIG_FILENAME} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, `Fix the JSON syntax in ${configPath}.`);
|
|
153
|
+
}
|
|
154
|
+
const result = EkanosProjectSchema.safeParse(raw);
|
|
155
|
+
if (!result.success) {
|
|
156
|
+
const first = result.error.issues[0];
|
|
157
|
+
const where = first && first.path.length > 0 ? first.path.join('.') : '(root)';
|
|
158
|
+
throw preconditionError(`${EKANOS_CONFIG_FILENAME} is invalid at "${where}": ${(_a = first === null || first === void 0 ? void 0 : first.message) !== null && _a !== void 0 ? _a : 'schema violation'}`, `Correct ${configPath} so it matches the ekanos.json schema ` +
|
|
159
|
+
`({ slug, entry } or { integrations: [...] }, plus optional ` +
|
|
160
|
+
`harness and sourceGlobs).`);
|
|
161
|
+
}
|
|
162
|
+
const project = result.data;
|
|
163
|
+
const integrations = projectIntegrations(project).map((declared) => {
|
|
164
|
+
const entryPath = path.resolve(projectDir, declared.entry);
|
|
165
|
+
if (!fs.existsSync(entryPath)) {
|
|
166
|
+
throw notFoundError(`entry "${declared.entry}" for "${declared.slug}" resolves to ` +
|
|
167
|
+
`${entryPath}, which does not exist.`, `Point it at your integration module (the file that exports the ` +
|
|
168
|
+
`defineIntegration(...) result) in ${configPath}.`);
|
|
169
|
+
}
|
|
170
|
+
return { slug: declared.slug, entry: declared.entry, entryPath };
|
|
171
|
+
});
|
|
172
|
+
assertEsmProject(projectDir);
|
|
173
|
+
return {
|
|
174
|
+
project,
|
|
175
|
+
projectDir,
|
|
176
|
+
configPath,
|
|
177
|
+
integrations,
|
|
178
|
+
primary: integrations[0],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Fail unless the project is an ES module package.
|
|
183
|
+
*
|
|
184
|
+
* `init` sets `"type": "module"` and says so, but a warning can be scrolled
|
|
185
|
+
* past, and a partner who misses it gets a confusing module-resolution error
|
|
186
|
+
* from deep inside Node or Next rather than a sentence naming the problem.
|
|
187
|
+
* This is the backstop: one precondition, in the loader every verb after
|
|
188
|
+
* `init` goes through, so the failure is loud and says what to do.
|
|
189
|
+
*
|
|
190
|
+
* `init` deliberately does NOT come through here — it is the thing that fixes
|
|
191
|
+
* this state.
|
|
192
|
+
*
|
|
193
|
+
* A project with no package.json at all is left alone: `dev` reports missing
|
|
194
|
+
* dependencies with its own better message, and `validate`/`test` have their
|
|
195
|
+
* own preconditions. We only assert on a manifest that exists and disagrees.
|
|
196
|
+
*/
|
|
197
|
+
export function assertEsmProject(projectDir) {
|
|
198
|
+
const manifestPath = path.join(projectDir, 'package.json');
|
|
199
|
+
if (!fs.existsSync(manifestPath))
|
|
200
|
+
return;
|
|
201
|
+
let manifest;
|
|
202
|
+
try {
|
|
203
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
204
|
+
}
|
|
205
|
+
catch (_a) {
|
|
206
|
+
// Malformed JSON is npm's problem to report, not ours to guess at; the
|
|
207
|
+
// partner will hit it on their next install with a better message.
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const declared = typeof manifest === 'object' && manifest !== null
|
|
211
|
+
? manifest.type
|
|
212
|
+
: undefined;
|
|
213
|
+
if (declared === 'module')
|
|
214
|
+
return;
|
|
215
|
+
throw preconditionError(declared === undefined
|
|
216
|
+
? `${manifestPath} does not declare "type": "module", so Node treats ` +
|
|
217
|
+
'your integration as CommonJS and cannot load it.'
|
|
218
|
+
: `${manifestPath} declares "type": ${JSON.stringify(declared)}, but the ` +
|
|
219
|
+
'integration sources are ES modules and Node cannot load them.', `Set "type": "module" in ${manifestPath}. If this project has CommonJS ` +
|
|
220
|
+
'sources of its own, put your integration in its own package instead — ' +
|
|
221
|
+
'the two module systems cannot share one package.json. Re-running ' +
|
|
222
|
+
'"ekanos init" also fixes this.');
|
|
223
|
+
}
|
|
224
|
+
/** Serialize an ekanos.json body with a trailing newline. */
|
|
225
|
+
export function serializeProject(project) {
|
|
226
|
+
return `${JSON.stringify(project, null, 2)}\n`;
|
|
227
|
+
}
|
|
228
|
+
//# sourceMappingURL=project.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"project.js","sourceRoot":"","sources":["../src/project.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport { z } from 'zod';\n\nimport { notFoundError, preconditionError } from './errors';\n\nconst SlugSchema = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {\n message:\n 'slug must be kebab-case ([a-z0-9] segments separated by single hyphens), e.g. \"acme-crm\".',\n});\n\nconst EntrySchema = z\n .string()\n .min(1, { message: 'entry must be a path to the integration module.' });\n\n/**\n * Extra Tailwind content globs, for an integration that does not live under\n * the default layout. They are interpolated into the generated\n * `styles/globals.css` as `@source` lines, so they are validated as\n * DATA-IN-CSS: project-relative, no escaping the project, and none of the\n * characters that could close the string or the surrounding comment.\n *\n * Being strict here is deliberate. A glob that matches nothing costs a partner\n * a silently unstyled integration; a glob that is rejected costs them one\n * clear error.\n */\nconst SOURCE_GLOB_PATTERN = /^[A-Za-z0-9_\\-./*{},[\\]!]+$/;\n\nconst SourceGlobSchema = z\n .string()\n .min(1, { message: 'a sourceGlobs entry must not be empty.' })\n .max(200, { message: 'a sourceGlobs entry must be under 200 characters.' })\n .regex(SOURCE_GLOB_PATTERN, {\n message:\n 'a sourceGlobs entry may contain only letters, digits and _-./*{},[]! ' +\n '— it is written verbatim into the generated stylesheet.',\n })\n .refine((glob) => !glob.startsWith('/'), {\n message: 'a sourceGlobs entry must be relative to the project root.',\n })\n .refine((glob) => !glob.split('/').includes('..'), {\n message: 'a sourceGlobs entry must not escape the project root with \"..\".',\n });\n\n/** One integration: the pair that addresses a definition module. */\nconst IntegrationEntrySchema = z\n .object({ slug: SlugSchema, entry: EntrySchema })\n .strict();\n\nexport type IntegrationEntry = z.infer<typeof IntegrationEntrySchema>;\n\n/**\n * `ekanos.json` — the project contract. Written by `init`, read by `validate`,\n * `dev` and `test`.\n *\n * Two shapes, because a project can hold more than one integration and the\n * single-integration form is what `init` writes and what most projects keep:\n *\n * ```json\n * { \"slug\": \"acme-crm\", \"entry\": \"src/integration.ts\" }\n *\n * { \"integrations\": [\n * { \"slug\": \"acme-crm\", \"entry\": \"src/crm.ts\" },\n * { \"slug\": \"acme-billing\", \"entry\": \"src/billing.ts\" }\n * ] }\n * ```\n *\n * A second integration used to be invisible to the CLI entirely — the harness\n * registry is an array and `collectCollisionFindings` exists precisely to\n * cross-check several definitions against each other, but `validate` could\n * only ever see one. Both forms normalise to the same list, so nothing\n * downstream has to know which was written.\n */\nexport const EkanosProjectSchema = z\n .object({\n $schema: z.string().optional(),\n slug: SlugSchema.optional(),\n entry: EntrySchema.optional(),\n integrations: z.array(IntegrationEntrySchema).optional(),\n // There is deliberately no `harness` field. One existed, accepted by the\n // schema and read by nothing — the same accept-and-ignore that made\n // `sourceGlobs` cost a partner a silently unstyled integration. `.strict()`\n // now rejects it by name, which is the honest answer until something\n // actually consumes it.\n sourceGlobs: z.array(SourceGlobSchema).optional(),\n })\n .strict()\n .superRefine((value, ctx) => {\n const hasSingle = value.slug !== undefined || value.entry !== undefined;\n const hasList = value.integrations !== undefined;\n\n if (hasSingle && hasList) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n message:\n 'use either the single form ({ slug, entry }) or the list form ' +\n '({ integrations: [...] }), not both — two places to declare the ' +\n 'same thing is how they drift apart.',\n });\n return;\n }\n\n if (hasList) {\n if (value.integrations!.length === 0) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['integrations'],\n message: 'integrations must list at least one integration.',\n });\n }\n const seen = new Set<string>();\n value.integrations!.forEach((entry, index) => {\n if (seen.has(entry.slug)) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['integrations', index, 'slug'],\n message:\n `duplicate slug \"${entry.slug}\" — every integration in a ` +\n 'project needs its own, since the slug addresses it everywhere.',\n });\n }\n seen.add(entry.slug);\n });\n return;\n }\n\n if (value.slug === undefined) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['slug'],\n message: 'slug is required (or use \"integrations\": [...]).',\n });\n }\n if (value.entry === undefined) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['entry'],\n message: 'entry is required (or use \"integrations\": [...]).',\n });\n }\n });\n\nexport type EkanosProject = z.infer<typeof EkanosProjectSchema>;\n\n/** Both `ekanos.json` shapes, flattened to the one the CLI works in. */\nexport function projectIntegrations(\n project: EkanosProject,\n): IntegrationEntry[] {\n if (project.integrations !== undefined) return [...project.integrations];\n return [{ slug: project.slug!, entry: project.entry! }];\n}\n\nexport const EKANOS_CONFIG_FILENAME = 'ekanos.json';\n\n/** One integration from `ekanos.json`, with its entry resolved on disk. */\nexport interface LoadedIntegration {\n slug: string;\n entry: string;\n /** Absolute path to the resolved entry module. */\n entryPath: string;\n}\n\nexport interface LoadedProject {\n project: EkanosProject;\n /** Absolute path to the directory containing ekanos.json (the project root). */\n projectDir: string;\n /** Absolute path to ekanos.json itself. */\n configPath: string;\n /** Every integration the project declares, in declaration order. */\n integrations: LoadedIntegration[];\n /**\n * The first integration. It names the generated harness shell and is what a\n * single-integration project means by \"the\" integration; nothing else\n * privileges it.\n */\n primary: LoadedIntegration;\n}\n\n/**\n * Read and validate ekanos.json from a directory. Missing file → not-found\n * (exit 6); malformed JSON or a schema violation → precondition failed\n * (exit 9), because a project the CLI cannot even read is a precondition for\n * every verb, not a definition-level validation finding.\n */\nexport function loadProject(dir: string): LoadedProject {\n const projectDir = path.resolve(dir);\n const configPath = path.join(projectDir, EKANOS_CONFIG_FILENAME);\n\n if (!fs.existsSync(configPath)) {\n throw notFoundError(\n `No ${EKANOS_CONFIG_FILENAME} found in ${projectDir}.`,\n `Run \"ekanos init\" here first, or change into the project directory ` +\n `that contains ${EKANOS_CONFIG_FILENAME}.`,\n );\n }\n\n let raw: unknown;\n try {\n raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));\n } catch (error) {\n throw preconditionError(\n `${EKANOS_CONFIG_FILENAME} is not valid JSON: ${\n error instanceof Error ? error.message : String(error)\n }`,\n `Fix the JSON syntax in ${configPath}.`,\n );\n }\n\n const result = EkanosProjectSchema.safeParse(raw);\n if (!result.success) {\n const first = result.error.issues[0];\n const where =\n first && first.path.length > 0 ? first.path.join('.') : '(root)';\n throw preconditionError(\n `${EKANOS_CONFIG_FILENAME} is invalid at \"${where}\": ${\n first?.message ?? 'schema violation'\n }`,\n `Correct ${configPath} so it matches the ekanos.json schema ` +\n `({ slug, entry } or { integrations: [...] }, plus optional ` +\n `harness and sourceGlobs).`,\n );\n }\n\n const project = result.data;\n\n const integrations = projectIntegrations(project).map((declared) => {\n const entryPath = path.resolve(projectDir, declared.entry);\n if (!fs.existsSync(entryPath)) {\n throw notFoundError(\n `entry \"${declared.entry}\" for \"${declared.slug}\" resolves to ` +\n `${entryPath}, which does not exist.`,\n `Point it at your integration module (the file that exports the ` +\n `defineIntegration(...) result) in ${configPath}.`,\n );\n }\n return { slug: declared.slug, entry: declared.entry, entryPath };\n });\n\n assertEsmProject(projectDir);\n\n return {\n project,\n projectDir,\n configPath,\n integrations,\n primary: integrations[0]!,\n };\n}\n\n/**\n * Fail unless the project is an ES module package.\n *\n * `init` sets `\"type\": \"module\"` and says so, but a warning can be scrolled\n * past, and a partner who misses it gets a confusing module-resolution error\n * from deep inside Node or Next rather than a sentence naming the problem.\n * This is the backstop: one precondition, in the loader every verb after\n * `init` goes through, so the failure is loud and says what to do.\n *\n * `init` deliberately does NOT come through here — it is the thing that fixes\n * this state.\n *\n * A project with no package.json at all is left alone: `dev` reports missing\n * dependencies with its own better message, and `validate`/`test` have their\n * own preconditions. We only assert on a manifest that exists and disagrees.\n */\nexport function assertEsmProject(projectDir: string): void {\n const manifestPath = path.join(projectDir, 'package.json');\n if (!fs.existsSync(manifestPath)) return;\n\n let manifest: unknown;\n try {\n manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));\n } catch {\n // Malformed JSON is npm's problem to report, not ours to guess at; the\n // partner will hit it on their next install with a better message.\n return;\n }\n\n const declared =\n typeof manifest === 'object' && manifest !== null\n ? (manifest as { type?: unknown }).type\n : undefined;\n\n if (declared === 'module') return;\n\n throw preconditionError(\n declared === undefined\n ? `${manifestPath} does not declare \"type\": \"module\", so Node treats ` +\n 'your integration as CommonJS and cannot load it.'\n : `${manifestPath} declares \"type\": ${JSON.stringify(declared)}, but the ` +\n 'integration sources are ES modules and Node cannot load them.',\n `Set \"type\": \"module\" in ${manifestPath}. If this project has CommonJS ` +\n 'sources of its own, put your integration in its own package instead — ' +\n 'the two module systems cannot share one package.json. Re-running ' +\n '\"ekanos init\" also fixes this.',\n );\n}\n\n/** Serialize an ekanos.json body with a trailing newline. */\nexport function serializeProject(project: EkanosProject): string {\n return `${JSON.stringify(project, null, 2)}\\n`;\n}\n"]}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem primitives for writing into a directory we do not fully trust.
|
|
3
|
+
*
|
|
4
|
+
* `ekanos dev` writes a generated tree into a partner's project, and every
|
|
5
|
+
* path it touches is one an attacker (or a careless `ln -s`) may have created
|
|
6
|
+
* first. Plain `existsSync` + `writeFileSync` follows symlinks, so a
|
|
7
|
+
* `.ekanos/harness` pointing at `~/.ssh`, a generated leaf pointing at a
|
|
8
|
+
* tracked source file, or a DANGLING link (which `existsSync` reports as
|
|
9
|
+
* absent, and which `writeFileSync` then materializes) all let a scaffold
|
|
10
|
+
* write outside the project.
|
|
11
|
+
*
|
|
12
|
+
* Two invariants close that, and both are enforced here rather than at the
|
|
13
|
+
* call sites, because a single unguarded write anywhere reopens the hole:
|
|
14
|
+
*
|
|
15
|
+
* 1. **Containment** — every path resolves inside the project root.
|
|
16
|
+
* 2. **No symlinks** — no component of a path we write through may be a
|
|
17
|
+
* symlink, and the write itself uses `O_NOFOLLOW` so the kernel enforces
|
|
18
|
+
* it at open time rather than trusting our earlier `lstat`. That closes
|
|
19
|
+
* the check→write TOCTOU window: a link swapped in after the check makes
|
|
20
|
+
* `open` fail with ELOOP instead of following.
|
|
21
|
+
*/
|
|
22
|
+
/** True when `target` is inside `root` (or is `root` itself). */
|
|
23
|
+
export declare function isContained(root: string, target: string): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Walk every path component from `root` down to `target` and refuse if any
|
|
26
|
+
* existing one is a symlink. `lstat` is deliberate — `stat` would resolve the
|
|
27
|
+
* link and report the target's type, which is exactly what we must not do.
|
|
28
|
+
*/
|
|
29
|
+
export declare function assertNoSymlinkedComponent(root: string, target: string): void;
|
|
30
|
+
/**
|
|
31
|
+
* `mkdir -p`, one guarded component at a time. `fs.mkdirSync(recursive)` is
|
|
32
|
+
* happy to walk through a symlinked ancestor; this is not.
|
|
33
|
+
*/
|
|
34
|
+
export declare function mkdirSafe(root: string, dir: string): void;
|
|
35
|
+
/**
|
|
36
|
+
* What `destPath` currently is, without following anything. `'symlink'` is
|
|
37
|
+
* reported for a dangling link too — the case `existsSync` calls absent and
|
|
38
|
+
* `writeFileSync` would silently materialize outside the project.
|
|
39
|
+
*/
|
|
40
|
+
export type PathKind = 'missing' | 'file' | 'directory' | 'symlink' | 'other';
|
|
41
|
+
export declare function classifyPath(destPath: string): PathKind;
|
|
42
|
+
/**
|
|
43
|
+
* Read a file, refusing to follow a symlink at the final component. Returns
|
|
44
|
+
* null when it does not exist. `O_NOFOLLOW` makes the kernel enforce this, so
|
|
45
|
+
* there is no window between deciding and reading.
|
|
46
|
+
*/
|
|
47
|
+
export declare function readFileNoFollow(root: string, filePath: string): string | null;
|
|
48
|
+
/**
|
|
49
|
+
* Write a file, creating parent directories, refusing every symlink on the
|
|
50
|
+
* way and at the leaf. `O_NOFOLLOW` on the final open is what makes this safe
|
|
51
|
+
* against a link swapped in after our checks.
|
|
52
|
+
*/
|
|
53
|
+
export declare function writeFileNoFollow(root: string, filePath: string, contents: string): void;
|
|
54
|
+
/** Append to an existing file under the same no-follow guarantee. */
|
|
55
|
+
export declare function appendFileNoFollow(root: string, filePath: string, contents: string): void;
|
package/dist/safe-fs.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
var _a;
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { invalidStateError } from './errors.js';
|
|
5
|
+
/**
|
|
6
|
+
* Filesystem primitives for writing into a directory we do not fully trust.
|
|
7
|
+
*
|
|
8
|
+
* `ekanos dev` writes a generated tree into a partner's project, and every
|
|
9
|
+
* path it touches is one an attacker (or a careless `ln -s`) may have created
|
|
10
|
+
* first. Plain `existsSync` + `writeFileSync` follows symlinks, so a
|
|
11
|
+
* `.ekanos/harness` pointing at `~/.ssh`, a generated leaf pointing at a
|
|
12
|
+
* tracked source file, or a DANGLING link (which `existsSync` reports as
|
|
13
|
+
* absent, and which `writeFileSync` then materializes) all let a scaffold
|
|
14
|
+
* write outside the project.
|
|
15
|
+
*
|
|
16
|
+
* Two invariants close that, and both are enforced here rather than at the
|
|
17
|
+
* call sites, because a single unguarded write anywhere reopens the hole:
|
|
18
|
+
*
|
|
19
|
+
* 1. **Containment** — every path resolves inside the project root.
|
|
20
|
+
* 2. **No symlinks** — no component of a path we write through may be a
|
|
21
|
+
* symlink, and the write itself uses `O_NOFOLLOW` so the kernel enforces
|
|
22
|
+
* it at open time rather than trusting our earlier `lstat`. That closes
|
|
23
|
+
* the check→write TOCTOU window: a link swapped in after the check makes
|
|
24
|
+
* `open` fail with ELOOP instead of following.
|
|
25
|
+
*/
|
|
26
|
+
/** True when `target` is inside `root` (or is `root` itself). */
|
|
27
|
+
export function isContained(root, target) {
|
|
28
|
+
const relative = path.relative(root, target);
|
|
29
|
+
if (relative === '')
|
|
30
|
+
return true;
|
|
31
|
+
return !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
32
|
+
}
|
|
33
|
+
function containmentError(root, target) {
|
|
34
|
+
throw invalidStateError(`Refusing to write ${target}: it resolves outside the project root ${root}.`, 'Remove the symlink or path component that redirects it, then re-run ' +
|
|
35
|
+
'"ekanos dev".');
|
|
36
|
+
}
|
|
37
|
+
function symlinkError(link) {
|
|
38
|
+
throw invalidStateError(`Refusing to write through the symlink ${link}.`, `"ekanos dev" only writes real files inside your project. Delete ${link} ` +
|
|
39
|
+
'(or the tree containing it) and re-run.');
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Walk every path component from `root` down to `target` and refuse if any
|
|
43
|
+
* existing one is a symlink. `lstat` is deliberate — `stat` would resolve the
|
|
44
|
+
* link and report the target's type, which is exactly what we must not do.
|
|
45
|
+
*/
|
|
46
|
+
export function assertNoSymlinkedComponent(root, target) {
|
|
47
|
+
if (!isContained(root, target))
|
|
48
|
+
containmentError(root, target);
|
|
49
|
+
const relative = path.relative(root, target);
|
|
50
|
+
if (relative === '')
|
|
51
|
+
return;
|
|
52
|
+
let current = root;
|
|
53
|
+
for (const segment of relative.split(path.sep)) {
|
|
54
|
+
current = path.join(current, segment);
|
|
55
|
+
let stats;
|
|
56
|
+
try {
|
|
57
|
+
stats = fs.lstatSync(current);
|
|
58
|
+
}
|
|
59
|
+
catch (_a) {
|
|
60
|
+
// Does not exist yet — nothing to follow, and everything below it is
|
|
61
|
+
// ours to create.
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (stats.isSymbolicLink())
|
|
65
|
+
symlinkError(current);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* `mkdir -p`, one guarded component at a time. `fs.mkdirSync(recursive)` is
|
|
70
|
+
* happy to walk through a symlinked ancestor; this is not.
|
|
71
|
+
*/
|
|
72
|
+
export function mkdirSafe(root, dir) {
|
|
73
|
+
if (!isContained(root, dir))
|
|
74
|
+
containmentError(root, dir);
|
|
75
|
+
const relative = path.relative(root, dir);
|
|
76
|
+
if (relative === '')
|
|
77
|
+
return;
|
|
78
|
+
let current = root;
|
|
79
|
+
for (const segment of relative.split(path.sep)) {
|
|
80
|
+
current = path.join(current, segment);
|
|
81
|
+
let stats = null;
|
|
82
|
+
try {
|
|
83
|
+
stats = fs.lstatSync(current);
|
|
84
|
+
}
|
|
85
|
+
catch (_a) {
|
|
86
|
+
stats = null;
|
|
87
|
+
}
|
|
88
|
+
if (stats === null) {
|
|
89
|
+
try {
|
|
90
|
+
fs.mkdirSync(current);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
const code = error.code;
|
|
94
|
+
// Lost a race to another process; re-check what landed there.
|
|
95
|
+
if (code !== 'EEXIST')
|
|
96
|
+
throw error;
|
|
97
|
+
stats = fs.lstatSync(current);
|
|
98
|
+
}
|
|
99
|
+
if (stats === null)
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (stats.isSymbolicLink())
|
|
103
|
+
symlinkError(current);
|
|
104
|
+
if (!stats.isDirectory()) {
|
|
105
|
+
throw invalidStateError(`${current} exists and is not a directory, so the harness shell ` +
|
|
106
|
+
'cannot be written there.', `Move or delete ${current}, then re-run "ekanos dev".`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export function classifyPath(destPath) {
|
|
111
|
+
let stats;
|
|
112
|
+
try {
|
|
113
|
+
stats = fs.lstatSync(destPath);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (error.code === 'ENOENT')
|
|
117
|
+
return 'missing';
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
if (stats.isSymbolicLink())
|
|
121
|
+
return 'symlink';
|
|
122
|
+
if (stats.isDirectory())
|
|
123
|
+
return 'directory';
|
|
124
|
+
if (stats.isFile())
|
|
125
|
+
return 'file';
|
|
126
|
+
return 'other';
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Read a file, refusing to follow a symlink at the final component. Returns
|
|
130
|
+
* null when it does not exist. `O_NOFOLLOW` makes the kernel enforce this, so
|
|
131
|
+
* there is no window between deciding and reading.
|
|
132
|
+
*/
|
|
133
|
+
export function readFileNoFollow(root, filePath) {
|
|
134
|
+
assertNoSymlinkedComponent(root, filePath);
|
|
135
|
+
let fd;
|
|
136
|
+
try {
|
|
137
|
+
fd = fs.openSync(filePath, fs.constants.O_RDONLY | O_NOFOLLOW);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
const code = error.code;
|
|
141
|
+
if (code === 'ENOENT')
|
|
142
|
+
return null;
|
|
143
|
+
if (code === 'ELOOP')
|
|
144
|
+
symlinkError(filePath);
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
return fs.readFileSync(fd, 'utf8');
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
fs.closeSync(fd);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Write a file, creating parent directories, refusing every symlink on the
|
|
156
|
+
* way and at the leaf. `O_NOFOLLOW` on the final open is what makes this safe
|
|
157
|
+
* against a link swapped in after our checks.
|
|
158
|
+
*/
|
|
159
|
+
export function writeFileNoFollow(root, filePath, contents) {
|
|
160
|
+
assertNoSymlinkedComponent(root, filePath);
|
|
161
|
+
mkdirSafe(root, path.dirname(filePath));
|
|
162
|
+
let fd;
|
|
163
|
+
try {
|
|
164
|
+
fd = fs.openSync(filePath, fs.constants.O_WRONLY |
|
|
165
|
+
fs.constants.O_CREAT |
|
|
166
|
+
fs.constants.O_TRUNC |
|
|
167
|
+
O_NOFOLLOW, 0o644);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
const code = error.code;
|
|
171
|
+
if (code === 'ELOOP')
|
|
172
|
+
symlinkError(filePath);
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
fs.writeFileSync(fd, contents);
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
fs.closeSync(fd);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Append to an existing file under the same no-follow guarantee. */
|
|
183
|
+
export function appendFileNoFollow(root, filePath, contents) {
|
|
184
|
+
assertNoSymlinkedComponent(root, filePath);
|
|
185
|
+
let fd;
|
|
186
|
+
try {
|
|
187
|
+
fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_APPEND | O_NOFOLLOW);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
const code = error.code;
|
|
191
|
+
if (code === 'ELOOP')
|
|
192
|
+
symlinkError(filePath);
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
fs.writeFileSync(fd, contents);
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
fs.closeSync(fd);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* `O_NOFOLLOW` is POSIX and present on Linux and macOS. Node exposes it as 0
|
|
204
|
+
* where the platform has no equivalent (Windows), in which case the explicit
|
|
205
|
+
* `lstat` walk above is the whole guarantee rather than a belt to the kernel's
|
|
206
|
+
* braces.
|
|
207
|
+
*/
|
|
208
|
+
const O_NOFOLLOW = (_a = fs.constants.O_NOFOLLOW) !== null && _a !== void 0 ? _a : 0;
|
|
209
|
+
//# sourceMappingURL=safe-fs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"safe-fs.js","sourceRoot":"","sources":["../src/safe-fs.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'node:fs';\nimport * as path from 'node:path';\n\nimport { invalidStateError } from './errors';\n\n/**\n * Filesystem primitives for writing into a directory we do not fully trust.\n *\n * `ekanos dev` writes a generated tree into a partner's project, and every\n * path it touches is one an attacker (or a careless `ln -s`) may have created\n * first. Plain `existsSync` + `writeFileSync` follows symlinks, so a\n * `.ekanos/harness` pointing at `~/.ssh`, a generated leaf pointing at a\n * tracked source file, or a DANGLING link (which `existsSync` reports as\n * absent, and which `writeFileSync` then materializes) all let a scaffold\n * write outside the project.\n *\n * Two invariants close that, and both are enforced here rather than at the\n * call sites, because a single unguarded write anywhere reopens the hole:\n *\n * 1. **Containment** — every path resolves inside the project root.\n * 2. **No symlinks** — no component of a path we write through may be a\n * symlink, and the write itself uses `O_NOFOLLOW` so the kernel enforces\n * it at open time rather than trusting our earlier `lstat`. That closes\n * the check→write TOCTOU window: a link swapped in after the check makes\n * `open` fail with ELOOP instead of following.\n */\n\n/** True when `target` is inside `root` (or is `root` itself). */\nexport function isContained(root: string, target: string): boolean {\n const relative = path.relative(root, target);\n if (relative === '') return true;\n return !relative.startsWith('..') && !path.isAbsolute(relative);\n}\n\nfunction containmentError(root: string, target: string): never {\n throw invalidStateError(\n `Refusing to write ${target}: it resolves outside the project root ${root}.`,\n 'Remove the symlink or path component that redirects it, then re-run ' +\n '\"ekanos dev\".',\n );\n}\n\nfunction symlinkError(link: string): never {\n throw invalidStateError(\n `Refusing to write through the symlink ${link}.`,\n `\"ekanos dev\" only writes real files inside your project. Delete ${link} ` +\n '(or the tree containing it) and re-run.',\n );\n}\n\n/**\n * Walk every path component from `root` down to `target` and refuse if any\n * existing one is a symlink. `lstat` is deliberate — `stat` would resolve the\n * link and report the target's type, which is exactly what we must not do.\n */\nexport function assertNoSymlinkedComponent(root: string, target: string): void {\n if (!isContained(root, target)) containmentError(root, target);\n\n const relative = path.relative(root, target);\n if (relative === '') return;\n\n let current = root;\n for (const segment of relative.split(path.sep)) {\n current = path.join(current, segment);\n let stats: fs.Stats;\n try {\n stats = fs.lstatSync(current);\n } catch {\n // Does not exist yet — nothing to follow, and everything below it is\n // ours to create.\n return;\n }\n if (stats.isSymbolicLink()) symlinkError(current);\n }\n}\n\n/**\n * `mkdir -p`, one guarded component at a time. `fs.mkdirSync(recursive)` is\n * happy to walk through a symlinked ancestor; this is not.\n */\nexport function mkdirSafe(root: string, dir: string): void {\n if (!isContained(root, dir)) containmentError(root, dir);\n\n const relative = path.relative(root, dir);\n if (relative === '') return;\n\n let current = root;\n for (const segment of relative.split(path.sep)) {\n current = path.join(current, segment);\n let stats: fs.Stats | null = null;\n try {\n stats = fs.lstatSync(current);\n } catch {\n stats = null;\n }\n\n if (stats === null) {\n try {\n fs.mkdirSync(current);\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n // Lost a race to another process; re-check what landed there.\n if (code !== 'EEXIST') throw error;\n stats = fs.lstatSync(current);\n }\n if (stats === null) continue;\n }\n\n if (stats.isSymbolicLink()) symlinkError(current);\n if (!stats.isDirectory()) {\n throw invalidStateError(\n `${current} exists and is not a directory, so the harness shell ` +\n 'cannot be written there.',\n `Move or delete ${current}, then re-run \"ekanos dev\".`,\n );\n }\n }\n}\n\n/**\n * What `destPath` currently is, without following anything. `'symlink'` is\n * reported for a dangling link too — the case `existsSync` calls absent and\n * `writeFileSync` would silently materialize outside the project.\n */\nexport type PathKind = 'missing' | 'file' | 'directory' | 'symlink' | 'other';\n\nexport function classifyPath(destPath: string): PathKind {\n let stats: fs.Stats;\n try {\n stats = fs.lstatSync(destPath);\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'missing';\n throw error;\n }\n if (stats.isSymbolicLink()) return 'symlink';\n if (stats.isDirectory()) return 'directory';\n if (stats.isFile()) return 'file';\n return 'other';\n}\n\n/**\n * Read a file, refusing to follow a symlink at the final component. Returns\n * null when it does not exist. `O_NOFOLLOW` makes the kernel enforce this, so\n * there is no window between deciding and reading.\n */\nexport function readFileNoFollow(\n root: string,\n filePath: string,\n): string | null {\n assertNoSymlinkedComponent(root, filePath);\n\n let fd: number;\n try {\n fd = fs.openSync(filePath, fs.constants.O_RDONLY | O_NOFOLLOW);\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n if (code === 'ENOENT') return null;\n if (code === 'ELOOP') symlinkError(filePath);\n throw error;\n }\n\n try {\n return fs.readFileSync(fd, 'utf8');\n } finally {\n fs.closeSync(fd);\n }\n}\n\n/**\n * Write a file, creating parent directories, refusing every symlink on the\n * way and at the leaf. `O_NOFOLLOW` on the final open is what makes this safe\n * against a link swapped in after our checks.\n */\nexport function writeFileNoFollow(\n root: string,\n filePath: string,\n contents: string,\n): void {\n assertNoSymlinkedComponent(root, filePath);\n mkdirSafe(root, path.dirname(filePath));\n\n let fd: number;\n try {\n fd = fs.openSync(\n filePath,\n fs.constants.O_WRONLY |\n fs.constants.O_CREAT |\n fs.constants.O_TRUNC |\n O_NOFOLLOW,\n 0o644,\n );\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n if (code === 'ELOOP') symlinkError(filePath);\n throw error;\n }\n\n try {\n fs.writeFileSync(fd, contents);\n } finally {\n fs.closeSync(fd);\n }\n}\n\n/** Append to an existing file under the same no-follow guarantee. */\nexport function appendFileNoFollow(\n root: string,\n filePath: string,\n contents: string,\n): void {\n assertNoSymlinkedComponent(root, filePath);\n\n let fd: number;\n try {\n fd = fs.openSync(\n filePath,\n fs.constants.O_WRONLY | fs.constants.O_APPEND | O_NOFOLLOW,\n );\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n if (code === 'ELOOP') symlinkError(filePath);\n throw error;\n }\n\n try {\n fs.writeFileSync(fd, contents);\n } finally {\n fs.closeSync(fd);\n }\n}\n\n/**\n * `O_NOFOLLOW` is POSIX and present on Linux and macOS. Node exposes it as 0\n * where the platform has no equivalent (Windows), in which case the explicit\n * `lstat` walk above is the whole guarantee rather than a belt to the kernel's\n * braces.\n */\nconst O_NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;\n"]}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bundled `templates/` directory, resolved relative to the compiled module
|
|
3
|
+
* so it works from `dist/` and from `src/` alike. `package.json#files` ships
|
|
4
|
+
* `templates` next to `dist`, so the `../..` hop is the same either way.
|
|
5
|
+
*/
|
|
6
|
+
export declare function templatesDir(): string;
|
|
7
|
+
/**
|
|
8
|
+
* The one substitution mechanism the CLI has: `__TOKEN__` placeholders,
|
|
9
|
+
* replaced literally. Deliberately not a template engine — a scaffolded file
|
|
10
|
+
* must be readable as-is in the repo, and every placeholder must be greppable.
|
|
11
|
+
* An unknown `__TOKEN__` left in a template is a bug, so this throws rather
|
|
12
|
+
* than shipping a placeholder into a partner's project.
|
|
13
|
+
*/
|
|
14
|
+
export declare function renderTemplate(source: string, vars: Readonly<Record<string, string>>): string;
|
|
15
|
+
/**
|
|
16
|
+
* The raw, unrendered source of a bundled template. Used for the compatibility
|
|
17
|
+
* fingerprint, which must not vary with the slug it would be rendered for.
|
|
18
|
+
*/
|
|
19
|
+
export declare function readTemplateSource(relativePath: string): string;
|
|
20
|
+
/** Read a bundled template and render it in one step. */
|
|
21
|
+
export declare function readTemplate(relativePath: string, vars: Readonly<Record<string, string>>): string;
|
|
22
|
+
/** "acme-crm" → "Acme Crm". */
|
|
23
|
+
export declare function toDisplayName(slug: string): string;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
/**
|
|
5
|
+
* The bundled `templates/` directory, resolved relative to the compiled module
|
|
6
|
+
* so it works from `dist/` and from `src/` alike. `package.json#files` ships
|
|
7
|
+
* `templates` next to `dist`, so the `../..` hop is the same either way.
|
|
8
|
+
*/
|
|
9
|
+
export function templatesDir() {
|
|
10
|
+
return fileURLToPath(new URL('../templates', import.meta.url));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The one substitution mechanism the CLI has: `__TOKEN__` placeholders,
|
|
14
|
+
* replaced literally. Deliberately not a template engine — a scaffolded file
|
|
15
|
+
* must be readable as-is in the repo, and every placeholder must be greppable.
|
|
16
|
+
* An unknown `__TOKEN__` left in a template is a bug, so this throws rather
|
|
17
|
+
* than shipping a placeholder into a partner's project.
|
|
18
|
+
*/
|
|
19
|
+
export function renderTemplate(source, vars) {
|
|
20
|
+
let rendered = source;
|
|
21
|
+
for (const [name, value] of Object.entries(vars)) {
|
|
22
|
+
rendered = rendered.split(`__${name}__`).join(value);
|
|
23
|
+
}
|
|
24
|
+
const leftover = /__[A-Z][A-Z0-9_]*__/.exec(rendered);
|
|
25
|
+
if (leftover) {
|
|
26
|
+
throw new Error(`Template placeholder "${leftover[0]}" was not substituted. This is a ` +
|
|
27
|
+
'bug in @ekanos/cli — the template and its variable set disagree.');
|
|
28
|
+
}
|
|
29
|
+
return rendered;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The raw, unrendered source of a bundled template. Used for the compatibility
|
|
33
|
+
* fingerprint, which must not vary with the slug it would be rendered for.
|
|
34
|
+
*/
|
|
35
|
+
export function readTemplateSource(relativePath) {
|
|
36
|
+
return fs.readFileSync(path.join(templatesDir(), relativePath), 'utf8');
|
|
37
|
+
}
|
|
38
|
+
/** Read a bundled template and render it in one step. */
|
|
39
|
+
export function readTemplate(relativePath, vars) {
|
|
40
|
+
return renderTemplate(readTemplateSource(relativePath), vars);
|
|
41
|
+
}
|
|
42
|
+
/** "acme-crm" → "Acme Crm". */
|
|
43
|
+
export function toDisplayName(slug) {
|
|
44
|
+
return slug
|
|
45
|
+
.split('-')
|
|
46
|
+
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
|
47
|
+
.join(' ');
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=templates.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"templates.js","sourceRoot":"","sources":["../src/templates.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,SAAS,CAAC;AAC9B,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAClC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC;;;;GAIG;AACH,MAAM,UAAU,YAAY;IAC1B,OAAO,aAAa,CAAC,IAAI,GAAG,CAAC,cAAc,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AACjE,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAC5B,MAAc,EACd,IAAsC;IAEtC,IAAI,QAAQ,GAAG,MAAM,CAAC;IACtB,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QACjD,QAAQ,GAAG,QAAQ,CAAC,KAAK,CAAC,KAAK,IAAI,IAAI,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACvD,CAAC;IAED,MAAM,QAAQ,GAAG,qBAAqB,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IACtD,IAAI,QAAQ,EAAE,CAAC;QACb,MAAM,IAAI,KAAK,CACb,yBAAyB,QAAQ,CAAC,CAAC,CAAC,mCAAmC;YACrE,kEAAkE,CACrE,CAAC;IACJ,CAAC;IAED,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CAAC,YAAoB;IACrD,OAAO,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,YAAY,EAAE,EAAE,YAAY,CAAC,EAAE,MAAM,CAAC,CAAC;AAC1E,CAAC;AAED,yDAAyD;AACzD,MAAM,UAAU,YAAY,CAC1B,YAAoB,EACpB,IAAsC;IAEtC,OAAO,cAAc,CAAC,kBAAkB,CAAC,YAAY,CAAC,EAAE,IAAI,CAAC,CAAC;AAChE,CAAC;AAED,+BAA+B;AAC/B,MAAM,UAAU,aAAa,CAAC,IAAY;IACxC,OAAO,IAAI;SACR,KAAK,CAAC,GAAG,CAAC;SACV,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;SACpE,IAAI,CAAC,GAAG,CAAC,CAAC;AACf,CAAC","sourcesContent":["import * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\n/**\n * The bundled `templates/` directory, resolved relative to the compiled module\n * so it works from `dist/` and from `src/` alike. `package.json#files` ships\n * `templates` next to `dist`, so the `../..` hop is the same either way.\n */\nexport function templatesDir(): string {\n return fileURLToPath(new URL('../templates', import.meta.url));\n}\n\n/**\n * The one substitution mechanism the CLI has: `__TOKEN__` placeholders,\n * replaced literally. Deliberately not a template engine — a scaffolded file\n * must be readable as-is in the repo, and every placeholder must be greppable.\n * An unknown `__TOKEN__` left in a template is a bug, so this throws rather\n * than shipping a placeholder into a partner's project.\n */\nexport function renderTemplate(\n source: string,\n vars: Readonly<Record<string, string>>,\n): string {\n let rendered = source;\n for (const [name, value] of Object.entries(vars)) {\n rendered = rendered.split(`__${name}__`).join(value);\n }\n\n const leftover = /__[A-Z][A-Z0-9_]*__/.exec(rendered);\n if (leftover) {\n throw new Error(\n `Template placeholder \"${leftover[0]}\" was not substituted. This is a ` +\n 'bug in @ekanos/cli — the template and its variable set disagree.',\n );\n }\n\n return rendered;\n}\n\n/**\n * The raw, unrendered source of a bundled template. Used for the compatibility\n * fingerprint, which must not vary with the slug it would be rendered for.\n */\nexport function readTemplateSource(relativePath: string): string {\n return fs.readFileSync(path.join(templatesDir(), relativePath), 'utf8');\n}\n\n/** Read a bundled template and render it in one step. */\nexport function readTemplate(\n relativePath: string,\n vars: Readonly<Record<string, string>>,\n): string {\n return renderTemplate(readTemplateSource(relativePath), vars);\n}\n\n/** \"acme-crm\" → \"Acme Crm\". */\nexport function toDisplayName(slug: string): string {\n return slug\n .split('-')\n .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))\n .join(' ');\n}\n"]}
|