@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,207 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { assertHarnessCompatible } from '../compatibility.js';
|
|
4
|
+
import { preconditionError, usageError } from '../errors.js';
|
|
5
|
+
import { HARNESS_DIR, HARNESS_PACKAGE, assertShellDirIsOurs, bootstrapHarnessConfig, ensureGitignoreEntry, missingDependencies, readInstalledHarness, scaffoldShell, } from '../harness-scaffold.js';
|
|
6
|
+
import { detectPackageManager, execArgs, formatCommand, installCommand, } from '../package-manager.js';
|
|
7
|
+
import { loadProject } from '../project.js';
|
|
8
|
+
const DEFAULT_PORT = 3100;
|
|
9
|
+
/**
|
|
10
|
+
* Loopback, NOT Next's default.
|
|
11
|
+
*
|
|
12
|
+
* `next dev` binds 0.0.0.0 unless told otherwise, so the harness would be
|
|
13
|
+
* reachable by anyone on the same network — a coffee-shop Wi-Fi, a shared
|
|
14
|
+
* office VLAN — while we printed "http://localhost:3100" and implied
|
|
15
|
+
* otherwise. It renders a partner's integration with their fixtures and, in
|
|
16
|
+
* live mode, whatever their vendor API returns; there is no auth in front of
|
|
17
|
+
* any of it. Binding loopback by default costs a partner nothing and makes
|
|
18
|
+
* exposure a thing they ask for.
|
|
19
|
+
*/
|
|
20
|
+
const DEFAULT_HOST = '127.0.0.1';
|
|
21
|
+
/** Addresses that are only reachable from this machine. */
|
|
22
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
|
|
23
|
+
/**
|
|
24
|
+
* `dev` — scaffold the local harness shell into the partner's project, then
|
|
25
|
+
* run it with their own Next.
|
|
26
|
+
*
|
|
27
|
+
* The shape of this verb follows from one rule: we must be able to ship a
|
|
28
|
+
* harness bugfix as an `@ekanos/harness` version bump that a partner picks up
|
|
29
|
+
* with `pnpm up`, with zero edits to any file they own. So everything under
|
|
30
|
+
* `.ekanos/` is generated, gitignored and rewritten freely, `harness.config.ts`
|
|
31
|
+
* at the project root is written once and never touched again, and all actual
|
|
32
|
+
* harness behaviour lives in the installed package.
|
|
33
|
+
*
|
|
34
|
+
* Two idempotency properties matter and are both tested: a second run produces
|
|
35
|
+
* byte-identical files and rewrites nothing (so Next's watcher stays quiet),
|
|
36
|
+
* and `.gitignore` never grows a duplicate line.
|
|
37
|
+
*/
|
|
38
|
+
export async function runDev(ctx, args) {
|
|
39
|
+
var _a, _b;
|
|
40
|
+
const loaded = loadProject(args.dir);
|
|
41
|
+
const port = parsePort(args.port);
|
|
42
|
+
const host = parseHost(args.host);
|
|
43
|
+
const exposed = !LOOPBACK_HOSTS.has(host);
|
|
44
|
+
const pm = detectPackageManager(loaded.projectDir);
|
|
45
|
+
// Fail before writing anything: the shell declares no dependencies of its
|
|
46
|
+
// own, so a missing one would otherwise surface as an opaque Next
|
|
47
|
+
// resolution error minutes later.
|
|
48
|
+
const missing = missingDependencies(loaded.projectDir);
|
|
49
|
+
if (missing.length > 0) {
|
|
50
|
+
throw preconditionError(`${missing.length} package${missing.length === 1 ? '' : 's'} the ` +
|
|
51
|
+
`harness needs ${missing.length === 1 ? 'is' : 'are'} not installed ` +
|
|
52
|
+
`in ${loaded.projectDir}: ${missing.join(', ')}.`, `Run "${installCommand(pm, missing)}" in ${loaded.projectDir}, then ` +
|
|
53
|
+
're-run "ekanos dev".');
|
|
54
|
+
}
|
|
55
|
+
const harness = readInstalledHarness(loaded.projectDir);
|
|
56
|
+
const harnessVersion = harness === null || harness === void 0 ? void 0 : harness.version;
|
|
57
|
+
if (!harnessVersion) {
|
|
58
|
+
throw preconditionError(`${HARNESS_PACKAGE} is installed but declares no version.`, `Reinstall it with "${installCommand(pm, [HARNESS_PACKAGE])}" in ` +
|
|
59
|
+
`${loaded.projectDir}, then re-run "ekanos dev".`);
|
|
60
|
+
}
|
|
61
|
+
// The templates ship with the CLI and the code they import ships with the
|
|
62
|
+
// harness, so a partner can upgrade one without the other. Name that
|
|
63
|
+
// mismatch here rather than letting it surface later as a module-resolution
|
|
64
|
+
// error inside a shell we cheerfully certified as current.
|
|
65
|
+
assertHarnessCompatible({
|
|
66
|
+
harnessVersion,
|
|
67
|
+
harnessManifest: harness === null || harness === void 0 ? void 0 : harness.manifest,
|
|
68
|
+
installHint: (spec) => installCommand(pm, [spec]),
|
|
69
|
+
});
|
|
70
|
+
// Validate the shell target BEFORE writing anything at all. Bootstrapping
|
|
71
|
+
// harness.config.ts first and only then discovering that .ekanos/harness is
|
|
72
|
+
// a file or someone else's tree would leave the project half-scaffolded.
|
|
73
|
+
assertShellDirIsOurs(path.join(loaded.projectDir, HARNESS_DIR), args.force);
|
|
74
|
+
const config = bootstrapHarnessConfig(loaded.projectDir, loaded.integrations);
|
|
75
|
+
const shell = scaffoldShell({
|
|
76
|
+
projectDir: loaded.projectDir,
|
|
77
|
+
slug: loaded.primary.slug,
|
|
78
|
+
harnessVersion,
|
|
79
|
+
force: args.force,
|
|
80
|
+
sourceGlobs: loaded.project.sourceGlobs,
|
|
81
|
+
});
|
|
82
|
+
const gitignoreUpdated = ensureGitignoreEntry(loaded.projectDir);
|
|
83
|
+
if (shell.stale) {
|
|
84
|
+
ctx.log(`Regenerating ${HARNESS_DIR} — ${shell.staleReasons.join('; ')}. ` +
|
|
85
|
+
`Nothing you own changed; ${config.configPath} is untouched.`);
|
|
86
|
+
}
|
|
87
|
+
if (config.bootstrapped) {
|
|
88
|
+
ctx.log(` created harness.config.ts (yours — edit it, we never will)`);
|
|
89
|
+
}
|
|
90
|
+
for (const file of shell.created)
|
|
91
|
+
ctx.log(` created ${HARNESS_DIR}/${file}`);
|
|
92
|
+
for (const file of shell.updated)
|
|
93
|
+
ctx.log(` updated ${HARNESS_DIR}/${file}`);
|
|
94
|
+
if (gitignoreUpdated)
|
|
95
|
+
ctx.log(' updated .gitignore (.ekanos/)');
|
|
96
|
+
// `--hostname` is passed ALWAYS, never left to Next's default, so the URL
|
|
97
|
+
// we print is the URL that is actually served.
|
|
98
|
+
const serverArgs = ['dev', '--port', String(port), '--hostname', host];
|
|
99
|
+
const command = formatCommand(pm.bin, execArgs(pm, 'next', serverArgs));
|
|
100
|
+
const url = `http://${displayHost(host)}:${port}`;
|
|
101
|
+
const scaffold = {
|
|
102
|
+
slug: loaded.primary.slug,
|
|
103
|
+
integrations: loaded.integrations.map((i) => i.slug),
|
|
104
|
+
projectDir: loaded.projectDir,
|
|
105
|
+
harnessDir: shell.harnessDir,
|
|
106
|
+
harnessVersion,
|
|
107
|
+
previousVersion: shell.previousVersion,
|
|
108
|
+
previousMarker: shell.previousMarker,
|
|
109
|
+
stale: shell.stale,
|
|
110
|
+
staleReasons: shell.staleReasons,
|
|
111
|
+
sourceGlobs: (_a = loaded.project.sourceGlobs) !== null && _a !== void 0 ? _a : [],
|
|
112
|
+
created: shell.created,
|
|
113
|
+
updated: shell.updated,
|
|
114
|
+
unchanged: shell.unchanged,
|
|
115
|
+
configBootstrapped: config.bootstrapped,
|
|
116
|
+
configPath: config.configPath,
|
|
117
|
+
gitignoreUpdated,
|
|
118
|
+
};
|
|
119
|
+
if (exposed) {
|
|
120
|
+
ctx.warn(`WARNING: --host ${host} exposes the harness beyond this machine. It ` +
|
|
121
|
+
'has no authentication, and it renders your fixtures and — in live ' +
|
|
122
|
+
'mode — real responses from your vendor API. Anyone who can reach ' +
|
|
123
|
+
`${url} can read all of it. Omit --host to bind ${DEFAULT_HOST}.`);
|
|
124
|
+
}
|
|
125
|
+
// A JSON caller is an agent or a pipe. Handing it a dev server that never
|
|
126
|
+
// exits would hang its tool call forever and it would never see the
|
|
127
|
+
// envelope, so JSON mode scaffolds and reports the command instead of
|
|
128
|
+
// running it. `--start` overrides; `--no-start` opts out in a terminal.
|
|
129
|
+
const shouldStart = (_b = args.start) !== null && _b !== void 0 ? _b : !ctx.jsonMode;
|
|
130
|
+
if (!shouldStart) {
|
|
131
|
+
return ctx.succeed(Object.assign(Object.assign({}, scaffold), { server: { started: false, command, url, port, host, exposed } }), `dev: harness ready in ${shell.harnessDir}. Start it with: ${command}`);
|
|
132
|
+
}
|
|
133
|
+
ctx.log(`Starting the harness on ${url} (${command})…`);
|
|
134
|
+
const exitCode = await spawnServer({
|
|
135
|
+
ctx,
|
|
136
|
+
bin: pm.bin,
|
|
137
|
+
argv: execArgs(pm, 'next', serverArgs),
|
|
138
|
+
cwd: shell.harnessDir,
|
|
139
|
+
});
|
|
140
|
+
const data = Object.assign(Object.assign({}, scaffold), { server: { started: true, command, url, port, host, exposed, exitCode } });
|
|
141
|
+
if (exitCode === 0) {
|
|
142
|
+
return ctx.succeed(data, `dev: harness stopped cleanly.`);
|
|
143
|
+
}
|
|
144
|
+
return ctx.failWith({
|
|
145
|
+
code: 'dev_server_failed',
|
|
146
|
+
message: `"${command}" exited with code ${exitCode}.`,
|
|
147
|
+
hint: 'Read the Next output on stderr. If it cannot resolve @ekanos/harness ' +
|
|
148
|
+
`or next, reinstall dependencies in ${loaded.projectDir}; if a route ` +
|
|
149
|
+
`fails to compile, delete ${shell.harnessDir} and re-run "ekanos dev".`,
|
|
150
|
+
// Transparent passthrough of Next's own exit code.
|
|
151
|
+
exitCode,
|
|
152
|
+
data,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
function spawnServer(params) {
|
|
156
|
+
return new Promise((resolve, reject) => {
|
|
157
|
+
var _a, _b;
|
|
158
|
+
const child = spawn(params.bin, params.argv, {
|
|
159
|
+
cwd: params.cwd,
|
|
160
|
+
// stdin inherited so Next's own key handling works; stdout/stderr piped
|
|
161
|
+
// so we can route them and keep OUR stdout reserved for the envelope.
|
|
162
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
163
|
+
});
|
|
164
|
+
(_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (chunk) => {
|
|
165
|
+
(params.ctx.jsonMode ? process.stderr : process.stdout).write(chunk);
|
|
166
|
+
});
|
|
167
|
+
(_b = child.stderr) === null || _b === void 0 ? void 0 : _b.on('data', (chunk) => {
|
|
168
|
+
process.stderr.write(chunk);
|
|
169
|
+
});
|
|
170
|
+
child.on('error', (error) => reject(error));
|
|
171
|
+
child.on('close', (code) => resolve(code !== null && code !== void 0 ? code : 1));
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The hostname to show a human. `127.0.0.1` is spelled `localhost` because
|
|
176
|
+
* that is what people type; a wildcard bind is spelled `localhost` too, but
|
|
177
|
+
* only after `exposed` has already been warned about — printing `0.0.0.0` as
|
|
178
|
+
* a URL is not useful.
|
|
179
|
+
*/
|
|
180
|
+
function displayHost(host) {
|
|
181
|
+
if (host === '127.0.0.1' || host === '0.0.0.0' || host === '::') {
|
|
182
|
+
return 'localhost';
|
|
183
|
+
}
|
|
184
|
+
if (host === '::1')
|
|
185
|
+
return '[::1]';
|
|
186
|
+
return host;
|
|
187
|
+
}
|
|
188
|
+
function parseHost(raw) {
|
|
189
|
+
if (raw === undefined)
|
|
190
|
+
return DEFAULT_HOST;
|
|
191
|
+
const host = raw.trim();
|
|
192
|
+
if (host.length === 0) {
|
|
193
|
+
throw usageError('Invalid --host "".', `Pass an interface to bind, e.g. "--host 0.0.0.0" to expose the ` +
|
|
194
|
+
`harness on your network, or omit it to bind ${DEFAULT_HOST}.`);
|
|
195
|
+
}
|
|
196
|
+
return host;
|
|
197
|
+
}
|
|
198
|
+
function parsePort(raw) {
|
|
199
|
+
if (raw === undefined)
|
|
200
|
+
return DEFAULT_PORT;
|
|
201
|
+
const port = Number(raw);
|
|
202
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
203
|
+
throw usageError(`Invalid --port "${raw}".`, 'Pass an integer between 1 and 65535, e.g. "--port 3100".');
|
|
204
|
+
}
|
|
205
|
+
return port;
|
|
206
|
+
}
|
|
207
|
+
//# sourceMappingURL=dev.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dev.js","sourceRoot":"","sources":["../../src/commands/dev.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn } from 'node:child_process';\nimport * as path from 'node:path';\n\nimport { assertHarnessCompatible } from '../compatibility';\nimport type { CliContext } from '../context';\nimport { preconditionError, usageError } from '../errors';\nimport {\n HARNESS_DIR,\n HARNESS_PACKAGE,\n assertShellDirIsOurs,\n bootstrapHarnessConfig,\n ensureGitignoreEntry,\n missingDependencies,\n readInstalledHarness,\n scaffoldShell,\n} from '../harness-scaffold';\nimport {\n detectPackageManager,\n execArgs,\n formatCommand,\n installCommand,\n} from '../package-manager';\nimport { loadProject } from '../project';\n\nexport interface DevArgs {\n /** Project directory (contains ekanos.json). Defaults to cwd. */\n dir: string;\n /** Port for the dev server. */\n port?: string;\n /**\n * Interface to bind. Defaults to loopback — see `resolveHost`. Anything\n * else is an explicit, warned-about decision to expose the harness.\n */\n host?: string;\n /** Rewrite every generated file even when its bytes already match. */\n force: boolean;\n /**\n * Explicit `--start` / `--no-start`. Undefined means \"decide from the\n * output mode\": a JSON caller is an agent or a pipe and must not be handed\n * a process that never exits.\n */\n start?: boolean;\n}\n\nconst DEFAULT_PORT = 3100;\n\n/**\n * Loopback, NOT Next's default.\n *\n * `next dev` binds 0.0.0.0 unless told otherwise, so the harness would be\n * reachable by anyone on the same network — a coffee-shop Wi-Fi, a shared\n * office VLAN — while we printed \"http://localhost:3100\" and implied\n * otherwise. It renders a partner's integration with their fixtures and, in\n * live mode, whatever their vendor API returns; there is no auth in front of\n * any of it. Binding loopback by default costs a partner nothing and makes\n * exposure a thing they ask for.\n */\nconst DEFAULT_HOST = '127.0.0.1';\n\n/** Addresses that are only reachable from this machine. */\nconst LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);\n\n/**\n * `dev` — scaffold the local harness shell into the partner's project, then\n * run it with their own Next.\n *\n * The shape of this verb follows from one rule: we must be able to ship a\n * harness bugfix as an `@ekanos/harness` version bump that a partner picks up\n * with `pnpm up`, with zero edits to any file they own. So everything under\n * `.ekanos/` is generated, gitignored and rewritten freely, `harness.config.ts`\n * at the project root is written once and never touched again, and all actual\n * harness behaviour lives in the installed package.\n *\n * Two idempotency properties matter and are both tested: a second run produces\n * byte-identical files and rewrites nothing (so Next's watcher stays quiet),\n * and `.gitignore` never grows a duplicate line.\n */\nexport async function runDev(ctx: CliContext, args: DevArgs): Promise<number> {\n const loaded = loadProject(args.dir);\n const port = parsePort(args.port);\n const host = parseHost(args.host);\n const exposed = !LOOPBACK_HOSTS.has(host);\n const pm = detectPackageManager(loaded.projectDir);\n\n // Fail before writing anything: the shell declares no dependencies of its\n // own, so a missing one would otherwise surface as an opaque Next\n // resolution error minutes later.\n const missing = missingDependencies(loaded.projectDir);\n if (missing.length > 0) {\n throw preconditionError(\n `${missing.length} package${missing.length === 1 ? '' : 's'} the ` +\n `harness needs ${missing.length === 1 ? 'is' : 'are'} not installed ` +\n `in ${loaded.projectDir}: ${missing.join(', ')}.`,\n `Run \"${installCommand(pm, missing)}\" in ${loaded.projectDir}, then ` +\n 're-run \"ekanos dev\".',\n );\n }\n\n const harness = readInstalledHarness(loaded.projectDir);\n const harnessVersion = harness?.version;\n if (!harnessVersion) {\n throw preconditionError(\n `${HARNESS_PACKAGE} is installed but declares no version.`,\n `Reinstall it with \"${installCommand(pm, [HARNESS_PACKAGE])}\" in ` +\n `${loaded.projectDir}, then re-run \"ekanos dev\".`,\n );\n }\n\n // The templates ship with the CLI and the code they import ships with the\n // harness, so a partner can upgrade one without the other. Name that\n // mismatch here rather than letting it surface later as a module-resolution\n // error inside a shell we cheerfully certified as current.\n assertHarnessCompatible({\n harnessVersion,\n harnessManifest: harness?.manifest,\n installHint: (spec) => installCommand(pm, [spec]),\n });\n\n // Validate the shell target BEFORE writing anything at all. Bootstrapping\n // harness.config.ts first and only then discovering that .ekanos/harness is\n // a file or someone else's tree would leave the project half-scaffolded.\n assertShellDirIsOurs(path.join(loaded.projectDir, HARNESS_DIR), args.force);\n\n const config = bootstrapHarnessConfig(loaded.projectDir, loaded.integrations);\n const shell = scaffoldShell({\n projectDir: loaded.projectDir,\n slug: loaded.primary.slug,\n harnessVersion,\n force: args.force,\n sourceGlobs: loaded.project.sourceGlobs,\n });\n const gitignoreUpdated = ensureGitignoreEntry(loaded.projectDir);\n\n if (shell.stale) {\n ctx.log(\n `Regenerating ${HARNESS_DIR} — ${shell.staleReasons.join('; ')}. ` +\n `Nothing you own changed; ${config.configPath} is untouched.`,\n );\n }\n\n if (config.bootstrapped) {\n ctx.log(` created harness.config.ts (yours — edit it, we never will)`);\n }\n for (const file of shell.created) ctx.log(` created ${HARNESS_DIR}/${file}`);\n for (const file of shell.updated) ctx.log(` updated ${HARNESS_DIR}/${file}`);\n if (gitignoreUpdated) ctx.log(' updated .gitignore (.ekanos/)');\n\n // `--hostname` is passed ALWAYS, never left to Next's default, so the URL\n // we print is the URL that is actually served.\n const serverArgs = ['dev', '--port', String(port), '--hostname', host];\n const command = formatCommand(pm.bin, execArgs(pm, 'next', serverArgs));\n const url = `http://${displayHost(host)}:${port}`;\n\n const scaffold = {\n slug: loaded.primary.slug,\n integrations: loaded.integrations.map((i) => i.slug),\n projectDir: loaded.projectDir,\n harnessDir: shell.harnessDir,\n harnessVersion,\n previousVersion: shell.previousVersion,\n previousMarker: shell.previousMarker,\n stale: shell.stale,\n staleReasons: shell.staleReasons,\n sourceGlobs: loaded.project.sourceGlobs ?? [],\n created: shell.created,\n updated: shell.updated,\n unchanged: shell.unchanged,\n configBootstrapped: config.bootstrapped,\n configPath: config.configPath,\n gitignoreUpdated,\n };\n\n if (exposed) {\n ctx.warn(\n `WARNING: --host ${host} exposes the harness beyond this machine. It ` +\n 'has no authentication, and it renders your fixtures and — in live ' +\n 'mode — real responses from your vendor API. Anyone who can reach ' +\n `${url} can read all of it. Omit --host to bind ${DEFAULT_HOST}.`,\n );\n }\n\n // A JSON caller is an agent or a pipe. Handing it a dev server that never\n // exits would hang its tool call forever and it would never see the\n // envelope, so JSON mode scaffolds and reports the command instead of\n // running it. `--start` overrides; `--no-start` opts out in a terminal.\n const shouldStart = args.start ?? !ctx.jsonMode;\n if (!shouldStart) {\n return ctx.succeed(\n {\n ...scaffold,\n server: { started: false, command, url, port, host, exposed },\n },\n `dev: harness ready in ${shell.harnessDir}. Start it with: ${command}`,\n );\n }\n\n ctx.log(`Starting the harness on ${url} (${command})…`);\n const exitCode = await spawnServer({\n ctx,\n bin: pm.bin,\n argv: execArgs(pm, 'next', serverArgs),\n cwd: shell.harnessDir,\n });\n\n const data = {\n ...scaffold,\n server: { started: true, command, url, port, host, exposed, exitCode },\n };\n\n if (exitCode === 0) {\n return ctx.succeed(data, `dev: harness stopped cleanly.`);\n }\n\n return ctx.failWith({\n code: 'dev_server_failed',\n message: `\"${command}\" exited with code ${exitCode}.`,\n hint:\n 'Read the Next output on stderr. If it cannot resolve @ekanos/harness ' +\n `or next, reinstall dependencies in ${loaded.projectDir}; if a route ` +\n `fails to compile, delete ${shell.harnessDir} and re-run \"ekanos dev\".`,\n // Transparent passthrough of Next's own exit code.\n exitCode,\n data,\n });\n}\n\nfunction spawnServer(params: {\n ctx: CliContext;\n bin: string;\n argv: string[];\n cwd: string;\n}): Promise<number> {\n return new Promise<number>((resolve, reject) => {\n const child = spawn(params.bin, params.argv, {\n cwd: params.cwd,\n // stdin inherited so Next's own key handling works; stdout/stderr piped\n // so we can route them and keep OUR stdout reserved for the envelope.\n stdio: ['inherit', 'pipe', 'pipe'],\n });\n\n child.stdout?.on('data', (chunk: Buffer) => {\n (params.ctx.jsonMode ? process.stderr : process.stdout).write(chunk);\n });\n child.stderr?.on('data', (chunk: Buffer) => {\n process.stderr.write(chunk);\n });\n\n child.on('error', (error) => reject(error));\n child.on('close', (code) => resolve(code ?? 1));\n });\n}\n\n/**\n * The hostname to show a human. `127.0.0.1` is spelled `localhost` because\n * that is what people type; a wildcard bind is spelled `localhost` too, but\n * only after `exposed` has already been warned about — printing `0.0.0.0` as\n * a URL is not useful.\n */\nfunction displayHost(host: string): string {\n if (host === '127.0.0.1' || host === '0.0.0.0' || host === '::') {\n return 'localhost';\n }\n if (host === '::1') return '[::1]';\n return host;\n}\n\nfunction parseHost(raw: string | undefined): string {\n if (raw === undefined) return DEFAULT_HOST;\n\n const host = raw.trim();\n if (host.length === 0) {\n throw usageError(\n 'Invalid --host \"\".',\n `Pass an interface to bind, e.g. \"--host 0.0.0.0\" to expose the ` +\n `harness on your network, or omit it to bind ${DEFAULT_HOST}.`,\n );\n }\n return host;\n}\n\nfunction parsePort(raw: string | undefined): number {\n if (raw === undefined) return DEFAULT_PORT;\n\n const port = Number(raw);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n throw usageError(\n `Invalid --port \"${raw}\".`,\n 'Pass an integer between 1 and 65535, e.g. \"--port 3100\".',\n );\n }\n return port;\n}\n"]}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CliContext } from '../context.js';
|
|
2
|
+
import type { ExitCode } from '../exit-codes.js';
|
|
3
|
+
/**
|
|
4
|
+
* A file init did not write, and what the partner should do about it.
|
|
5
|
+
*
|
|
6
|
+
* Every ERROR path in this CLI carries an imperative hint; a skip did not, and
|
|
7
|
+
* that asymmetry was the bug — `skipped package.json (already exists)` hid the
|
|
8
|
+
* loss of eleven dependencies and the project's module type.
|
|
9
|
+
*/
|
|
10
|
+
export interface SkippedFile {
|
|
11
|
+
file: string;
|
|
12
|
+
reason: string;
|
|
13
|
+
hint: string;
|
|
14
|
+
}
|
|
15
|
+
export interface InitArgs {
|
|
16
|
+
slug?: string;
|
|
17
|
+
dir: string;
|
|
18
|
+
force: boolean;
|
|
19
|
+
/** Accepted for non-interactive ergonomics; init never prompts regardless. */
|
|
20
|
+
yes: boolean;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* `init` — instantiate a project from the bundled template: ekanos.json, a
|
|
24
|
+
* working minimal defineIntegration, a test, a vitest config (with the
|
|
25
|
+
* required SDK inline block), a .gitignore, and a package.json with real
|
|
26
|
+
* published deps. Idempotent: an existing file is left untouched (reported as
|
|
27
|
+
* skipped) unless --force is passed. Never prompts — a missing --slug fails
|
|
28
|
+
* with a usage error naming the flag.
|
|
29
|
+
*/
|
|
30
|
+
export declare function runInit(ctx: CliContext, args: InitArgs): ExitCode;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { usageError } from '../errors.js';
|
|
4
|
+
import { mergeManifest, serializeManifest, } from '../merge-manifest.js';
|
|
5
|
+
import { readTemplate, toDisplayName } from '../templates.js';
|
|
6
|
+
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
7
|
+
/**
|
|
8
|
+
* The files init writes: template basename → destination path.
|
|
9
|
+
*
|
|
10
|
+
* `package.json` is handled separately (see `writeManifest`) because it is the
|
|
11
|
+
* one file a partner plausibly already has — `npm init -y` before `ekanos
|
|
12
|
+
* init` is the ordinary way to start — and skipping it silently costs them
|
|
13
|
+
* every dependency and script.
|
|
14
|
+
*/
|
|
15
|
+
const FILES = [
|
|
16
|
+
{ template: 'ekanos.json.tmpl', dest: 'ekanos.json' },
|
|
17
|
+
{ template: 'gitignore.tmpl', dest: '.gitignore' },
|
|
18
|
+
// Without this, the `typecheck` script below prints tsc's help and exits 1 —
|
|
19
|
+
// and, worse, no type error in the scaffold ever surfaces.
|
|
20
|
+
{ template: 'tsconfig.json.tmpl', dest: 'tsconfig.json' },
|
|
21
|
+
{ template: 'vitest.config.ts.tmpl', dest: 'vitest.config.ts' },
|
|
22
|
+
{ template: 'src/integration.ts.tmpl', dest: 'src/integration.ts' },
|
|
23
|
+
{
|
|
24
|
+
template: 'src/integration.test.ts.tmpl',
|
|
25
|
+
dest: 'src/integration.test.ts',
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* `init` — instantiate a project from the bundled template: ekanos.json, a
|
|
30
|
+
* working minimal defineIntegration, a test, a vitest config (with the
|
|
31
|
+
* required SDK inline block), a .gitignore, and a package.json with real
|
|
32
|
+
* published deps. Idempotent: an existing file is left untouched (reported as
|
|
33
|
+
* skipped) unless --force is passed. Never prompts — a missing --slug fails
|
|
34
|
+
* with a usage error naming the flag.
|
|
35
|
+
*/
|
|
36
|
+
export function runInit(ctx, args) {
|
|
37
|
+
const slug = args.slug;
|
|
38
|
+
if (!slug) {
|
|
39
|
+
throw usageError('init requires a slug.', 'Pass it explicitly: "ekanos init --slug <your-integration> --yes". ' +
|
|
40
|
+
'The CLI never prompts, so the slug must be on the command line.');
|
|
41
|
+
}
|
|
42
|
+
if (!SLUG_PATTERN.test(slug)) {
|
|
43
|
+
throw usageError(`Invalid slug "${slug}".`, 'Use kebab-case: lowercase [a-z0-9] segments joined by single hyphens, ' +
|
|
44
|
+
'e.g. "acme-crm".');
|
|
45
|
+
}
|
|
46
|
+
const targetDir = path.resolve(args.dir);
|
|
47
|
+
const vars = { SLUG: slug, DISPLAY_NAME: toDisplayName(slug) };
|
|
48
|
+
const created = [];
|
|
49
|
+
const skipped = [];
|
|
50
|
+
for (const { template, dest } of FILES) {
|
|
51
|
+
const destPath = path.join(targetDir, dest);
|
|
52
|
+
if (fs.existsSync(destPath) && !args.force) {
|
|
53
|
+
skipped.push({
|
|
54
|
+
file: dest,
|
|
55
|
+
reason: 'it already exists',
|
|
56
|
+
hint: `Compare it against the scaffold and merge anything you are ` +
|
|
57
|
+
`missing, or re-run with --force to overwrite ${dest}.`,
|
|
58
|
+
});
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
62
|
+
fs.writeFileSync(destPath, readTemplate(template, vars));
|
|
63
|
+
created.push(dest);
|
|
64
|
+
}
|
|
65
|
+
const manifest = writeManifest(targetDir, vars, args.force);
|
|
66
|
+
if (manifest.kind === 'created')
|
|
67
|
+
created.push('package.json');
|
|
68
|
+
for (const file of created)
|
|
69
|
+
ctx.log(` created ${file}`);
|
|
70
|
+
if (manifest.kind === 'merged') {
|
|
71
|
+
ctx.log(` merged package.json (added ${manifest.added.length} field${manifest.added.length === 1 ? '' : 's'})`);
|
|
72
|
+
}
|
|
73
|
+
if (manifest.kind === 'unchanged') {
|
|
74
|
+
ctx.log(' package.json already had everything the scaffold adds');
|
|
75
|
+
}
|
|
76
|
+
for (const skip of skipped) {
|
|
77
|
+
ctx.log(` skipped ${skip.file} — ${skip.reason}. ${skip.hint}`);
|
|
78
|
+
}
|
|
79
|
+
for (const note of manifest.notes) {
|
|
80
|
+
ctx.warn(` ${note.message} ${note.hint}`);
|
|
81
|
+
}
|
|
82
|
+
const data = {
|
|
83
|
+
slug,
|
|
84
|
+
dir: targetDir,
|
|
85
|
+
created,
|
|
86
|
+
skipped,
|
|
87
|
+
manifest: {
|
|
88
|
+
action: manifest.kind,
|
|
89
|
+
added: manifest.added,
|
|
90
|
+
kept: manifest.kept,
|
|
91
|
+
notes: manifest.notes,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
const summary = summarize({
|
|
95
|
+
slug,
|
|
96
|
+
created,
|
|
97
|
+
skipped,
|
|
98
|
+
manifestAction: manifest.kind,
|
|
99
|
+
manifestAdded: manifest.added.length,
|
|
100
|
+
noteCount: manifest.notes.length,
|
|
101
|
+
});
|
|
102
|
+
return ctx.succeed(data, summary);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Write or MERGE package.json.
|
|
106
|
+
*
|
|
107
|
+
* A partner who ran `npm init -y` first had a manifest with none of the
|
|
108
|
+
* scaffold's dependencies and `"type": "commonjs"` under ESM sources, and the
|
|
109
|
+
* old behaviour reported that as one word: "skipped". Merging is additive — a
|
|
110
|
+
* version or script they already set is theirs and is never touched — so the
|
|
111
|
+
* ordinary `npm init -y` path now lands somewhere that installs and runs.
|
|
112
|
+
*/
|
|
113
|
+
function writeManifest(targetDir, vars, force) {
|
|
114
|
+
const destPath = path.join(targetDir, 'package.json');
|
|
115
|
+
const rendered = readTemplate('package.json.tmpl', vars);
|
|
116
|
+
if (!fs.existsSync(destPath) || force) {
|
|
117
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
118
|
+
fs.writeFileSync(destPath, rendered);
|
|
119
|
+
return { kind: 'created', added: [], kept: [], notes: [] };
|
|
120
|
+
}
|
|
121
|
+
const template = JSON.parse(rendered);
|
|
122
|
+
let existing;
|
|
123
|
+
try {
|
|
124
|
+
existing = JSON.parse(fs.readFileSync(destPath, 'utf8'));
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
// Unparseable: do not guess, and do not clobber. Name the fix.
|
|
128
|
+
return {
|
|
129
|
+
kind: 'unchanged',
|
|
130
|
+
added: [],
|
|
131
|
+
kept: [],
|
|
132
|
+
notes: [
|
|
133
|
+
{
|
|
134
|
+
field: '(root)',
|
|
135
|
+
message: `package.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
136
|
+
hint: 'Fix the JSON syntax, then re-run "ekanos init" to have the ' +
|
|
137
|
+
"scaffold's dependencies and scripts merged in.",
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const result = mergeManifest(existing, template);
|
|
143
|
+
if (!result.changed) {
|
|
144
|
+
return {
|
|
145
|
+
kind: 'unchanged',
|
|
146
|
+
added: [],
|
|
147
|
+
kept: result.kept,
|
|
148
|
+
notes: result.notes,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
fs.writeFileSync(destPath, serializeManifest(result.manifest));
|
|
152
|
+
return {
|
|
153
|
+
kind: 'merged',
|
|
154
|
+
added: result.added,
|
|
155
|
+
kept: result.kept,
|
|
156
|
+
notes: result.notes,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function summarize(params) {
|
|
160
|
+
const parts = [];
|
|
161
|
+
if (params.created.length > 0) {
|
|
162
|
+
parts.push(`${params.created.length} file${params.created.length === 1 ? '' : 's'} written`);
|
|
163
|
+
}
|
|
164
|
+
if (params.manifestAction === 'merged') {
|
|
165
|
+
parts.push(`${params.manifestAdded} package.json field(s) merged`);
|
|
166
|
+
}
|
|
167
|
+
if (params.skipped.length > 0) {
|
|
168
|
+
parts.push(`${params.skipped.length} skipped`);
|
|
169
|
+
}
|
|
170
|
+
if (params.noteCount > 0) {
|
|
171
|
+
parts.push(`${params.noteCount} thing(s) need your attention`);
|
|
172
|
+
}
|
|
173
|
+
if (parts.length === 0) {
|
|
174
|
+
return `init: nothing to do — "${params.slug}" is already scaffolded.`;
|
|
175
|
+
}
|
|
176
|
+
return `init: scaffolded "${params.slug}" (${parts.join(', ')}).`;
|
|
177
|
+
}
|
|
178
|
+
//# sourceMappingURL=init.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"init.js","sourceRoot":"","sources":["../../src/commands/init.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'node:fs';\nimport * as path from 'node:path';\n\nimport type { CliContext } from '../context';\nimport { usageError } from '../errors';\nimport type { ExitCode } from '../exit-codes';\nimport {\n type Manifest,\n type ManifestNote,\n mergeManifest,\n serializeManifest,\n} from '../merge-manifest';\nimport { readTemplate, toDisplayName } from '../templates';\n\n/**\n * A file init did not write, and what the partner should do about it.\n *\n * Every ERROR path in this CLI carries an imperative hint; a skip did not, and\n * that asymmetry was the bug — `skipped package.json (already exists)` hid the\n * loss of eleven dependencies and the project's module type.\n */\nexport interface SkippedFile {\n file: string;\n reason: string;\n hint: string;\n}\n\nexport interface InitArgs {\n slug?: string;\n dir: string;\n force: boolean;\n /** Accepted for non-interactive ergonomics; init never prompts regardless. */\n yes: boolean;\n}\n\nconst SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;\n\n/**\n * The files init writes: template basename → destination path.\n *\n * `package.json` is handled separately (see `writeManifest`) because it is the\n * one file a partner plausibly already has — `npm init -y` before `ekanos\n * init` is the ordinary way to start — and skipping it silently costs them\n * every dependency and script.\n */\nconst FILES: { template: string; dest: string }[] = [\n { template: 'ekanos.json.tmpl', dest: 'ekanos.json' },\n { template: 'gitignore.tmpl', dest: '.gitignore' },\n // Without this, the `typecheck` script below prints tsc's help and exits 1 —\n // and, worse, no type error in the scaffold ever surfaces.\n { template: 'tsconfig.json.tmpl', dest: 'tsconfig.json' },\n { template: 'vitest.config.ts.tmpl', dest: 'vitest.config.ts' },\n { template: 'src/integration.ts.tmpl', dest: 'src/integration.ts' },\n {\n template: 'src/integration.test.ts.tmpl',\n dest: 'src/integration.test.ts',\n },\n];\n\n/**\n * `init` — instantiate a project from the bundled template: ekanos.json, a\n * working minimal defineIntegration, a test, a vitest config (with the\n * required SDK inline block), a .gitignore, and a package.json with real\n * published deps. Idempotent: an existing file is left untouched (reported as\n * skipped) unless --force is passed. Never prompts — a missing --slug fails\n * with a usage error naming the flag.\n */\nexport function runInit(ctx: CliContext, args: InitArgs): ExitCode {\n const slug = args.slug;\n if (!slug) {\n throw usageError(\n 'init requires a slug.',\n 'Pass it explicitly: \"ekanos init --slug <your-integration> --yes\". ' +\n 'The CLI never prompts, so the slug must be on the command line.',\n );\n }\n if (!SLUG_PATTERN.test(slug)) {\n throw usageError(\n `Invalid slug \"${slug}\".`,\n 'Use kebab-case: lowercase [a-z0-9] segments joined by single hyphens, ' +\n 'e.g. \"acme-crm\".',\n );\n }\n\n const targetDir = path.resolve(args.dir);\n const vars = { SLUG: slug, DISPLAY_NAME: toDisplayName(slug) };\n\n const created: string[] = [];\n const skipped: SkippedFile[] = [];\n\n for (const { template, dest } of FILES) {\n const destPath = path.join(targetDir, dest);\n if (fs.existsSync(destPath) && !args.force) {\n skipped.push({\n file: dest,\n reason: 'it already exists',\n hint:\n `Compare it against the scaffold and merge anything you are ` +\n `missing, or re-run with --force to overwrite ${dest}.`,\n });\n continue;\n }\n\n fs.mkdirSync(path.dirname(destPath), { recursive: true });\n fs.writeFileSync(destPath, readTemplate(template, vars));\n created.push(dest);\n }\n\n const manifest = writeManifest(targetDir, vars, args.force);\n if (manifest.kind === 'created') created.push('package.json');\n\n for (const file of created) ctx.log(` created ${file}`);\n if (manifest.kind === 'merged') {\n ctx.log(\n ` merged package.json (added ${manifest.added.length} field${\n manifest.added.length === 1 ? '' : 's'\n })`,\n );\n }\n if (manifest.kind === 'unchanged') {\n ctx.log(' package.json already had everything the scaffold adds');\n }\n for (const skip of skipped) {\n ctx.log(` skipped ${skip.file} — ${skip.reason}. ${skip.hint}`);\n }\n for (const note of manifest.notes) {\n ctx.warn(` ${note.message} ${note.hint}`);\n }\n\n const data = {\n slug,\n dir: targetDir,\n created,\n skipped,\n manifest: {\n action: manifest.kind,\n added: manifest.added,\n kept: manifest.kept,\n notes: manifest.notes,\n },\n };\n\n const summary = summarize({\n slug,\n created,\n skipped,\n manifestAction: manifest.kind,\n manifestAdded: manifest.added.length,\n noteCount: manifest.notes.length,\n });\n\n return ctx.succeed(data, summary);\n}\n\ntype ManifestOutcome = {\n kind: 'created' | 'merged' | 'unchanged';\n added: string[];\n kept: string[];\n notes: ManifestNote[];\n};\n\n/**\n * Write or MERGE package.json.\n *\n * A partner who ran `npm init -y` first had a manifest with none of the\n * scaffold's dependencies and `\"type\": \"commonjs\"` under ESM sources, and the\n * old behaviour reported that as one word: \"skipped\". Merging is additive — a\n * version or script they already set is theirs and is never touched — so the\n * ordinary `npm init -y` path now lands somewhere that installs and runs.\n */\nfunction writeManifest(\n targetDir: string,\n vars: Readonly<Record<string, string>>,\n force: boolean,\n): ManifestOutcome {\n const destPath = path.join(targetDir, 'package.json');\n const rendered = readTemplate('package.json.tmpl', vars);\n\n if (!fs.existsSync(destPath) || force) {\n fs.mkdirSync(path.dirname(destPath), { recursive: true });\n fs.writeFileSync(destPath, rendered);\n return { kind: 'created', added: [], kept: [], notes: [] };\n }\n\n const template = JSON.parse(rendered) as Manifest;\n\n let existing: Manifest;\n try {\n existing = JSON.parse(fs.readFileSync(destPath, 'utf8')) as Manifest;\n } catch (error) {\n // Unparseable: do not guess, and do not clobber. Name the fix.\n return {\n kind: 'unchanged',\n added: [],\n kept: [],\n notes: [\n {\n field: '(root)',\n message: `package.json is not valid JSON: ${\n error instanceof Error ? error.message : String(error)\n }`,\n hint:\n 'Fix the JSON syntax, then re-run \"ekanos init\" to have the ' +\n \"scaffold's dependencies and scripts merged in.\",\n },\n ],\n };\n }\n\n const result = mergeManifest(existing, template);\n if (!result.changed) {\n return {\n kind: 'unchanged',\n added: [],\n kept: result.kept,\n notes: result.notes,\n };\n }\n\n fs.writeFileSync(destPath, serializeManifest(result.manifest));\n return {\n kind: 'merged',\n added: result.added,\n kept: result.kept,\n notes: result.notes,\n };\n}\n\nfunction summarize(params: {\n slug: string;\n created: string[];\n skipped: SkippedFile[];\n manifestAction: ManifestOutcome['kind'];\n manifestAdded: number;\n noteCount: number;\n}): string {\n const parts: string[] = [];\n if (params.created.length > 0) {\n parts.push(\n `${params.created.length} file${params.created.length === 1 ? '' : 's'} written`,\n );\n }\n if (params.manifestAction === 'merged') {\n parts.push(`${params.manifestAdded} package.json field(s) merged`);\n }\n if (params.skipped.length > 0) {\n parts.push(`${params.skipped.length} skipped`);\n }\n if (params.noteCount > 0) {\n parts.push(`${params.noteCount} thing(s) need your attention`);\n }\n\n if (parts.length === 0) {\n return `init: nothing to do — \"${params.slug}\" is already scaffolded.`;\n }\n return `init: scaffolded \"${params.slug}\" (${parts.join(', ')}).`;\n}\n"]}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CliContext } from '../context.js';
|
|
2
|
+
import type { ExitCode } from '../exit-codes.js';
|
|
3
|
+
export interface LoginArgs {
|
|
4
|
+
host?: string;
|
|
5
|
+
/** Start a new browser login even if a working session is already stored. */
|
|
6
|
+
force: boolean;
|
|
7
|
+
env: Record<string, string | undefined>;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* `login` — authenticate this machine against a Fusion deployment via the
|
|
11
|
+
* Auth0 device authorization flow.
|
|
12
|
+
*
|
|
13
|
+
* Idempotent: with a stored session that still authenticates, it reports that
|
|
14
|
+
* and exits 0 without opening a browser. `--force` skips the check.
|
|
15
|
+
*
|
|
16
|
+
* The user code and verification URL go to STDERR. They have to be visible
|
|
17
|
+
* even when stdout is a pipe into an agent, and stdout in this CLI carries
|
|
18
|
+
* exactly one JSON object.
|
|
19
|
+
*/
|
|
20
|
+
export declare function runLogin(ctx: CliContext, args: LoginArgs): Promise<ExitCode>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { pollForDeviceTokens, startDeviceAuthorization, } from '../auth/device-flow.js';
|
|
2
|
+
import { createFusionSession, fetchCliAuthConfig } from '../auth/fusion-api.js';
|
|
3
|
+
import { resolveAuthEnvironment, resolveIdentity } from '../auth/session.js';
|
|
4
|
+
import { CliError } from '../errors.js';
|
|
5
|
+
import { ERROR_CODES } from '../exit-codes.js';
|
|
6
|
+
/**
|
|
7
|
+
* `login` — authenticate this machine against a Fusion deployment via the
|
|
8
|
+
* Auth0 device authorization flow.
|
|
9
|
+
*
|
|
10
|
+
* Idempotent: with a stored session that still authenticates, it reports that
|
|
11
|
+
* and exits 0 without opening a browser. `--force` skips the check.
|
|
12
|
+
*
|
|
13
|
+
* The user code and verification URL go to STDERR. They have to be visible
|
|
14
|
+
* even when stdout is a pipe into an agent, and stdout in this CLI carries
|
|
15
|
+
* exactly one JSON object.
|
|
16
|
+
*/
|
|
17
|
+
export async function runLogin(ctx, args) {
|
|
18
|
+
var _a, _b, _c;
|
|
19
|
+
const env = resolveAuthEnvironment(ctx, args.host, args.env);
|
|
20
|
+
if (!args.force) {
|
|
21
|
+
const existing = env.store.read(env.host);
|
|
22
|
+
if (existing) {
|
|
23
|
+
const reusable = await tryReuse(env, existing);
|
|
24
|
+
if (reusable) {
|
|
25
|
+
return ctx.succeed({
|
|
26
|
+
host: env.host,
|
|
27
|
+
status: 'already-authenticated',
|
|
28
|
+
user: reusable.user,
|
|
29
|
+
expiresAt: reusable.expiresAt,
|
|
30
|
+
credentialsPath: env.store.filePath,
|
|
31
|
+
}, `login: already authenticated to ${env.host} as ${(_a = reusable.user.email) !== null && _a !== void 0 ? _a : reusable.user.id}.`);
|
|
32
|
+
}
|
|
33
|
+
ctx.log('The stored session has expired. Starting a new login…');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const config = await fetchCliAuthConfig(env.host);
|
|
37
|
+
const authorization = await startDeviceAuthorization(config);
|
|
38
|
+
ctx.log('');
|
|
39
|
+
ctx.log(` Open: ${(_b = authorization.verificationUriComplete) !== null && _b !== void 0 ? _b : authorization.verificationUri}`);
|
|
40
|
+
ctx.log(` Code: ${authorization.userCode}`);
|
|
41
|
+
if (authorization.verificationUriComplete) {
|
|
42
|
+
ctx.log(` (without the pre-filled code: ${authorization.verificationUri})`);
|
|
43
|
+
}
|
|
44
|
+
ctx.log('');
|
|
45
|
+
ctx.log('Waiting for approval…');
|
|
46
|
+
const tokens = await pollForDeviceTokens(config, authorization);
|
|
47
|
+
const session = await createFusionSession(env.host, tokens.accessToken);
|
|
48
|
+
const stored = {
|
|
49
|
+
auth0RefreshToken: tokens.refreshToken,
|
|
50
|
+
accessToken: session.accessToken,
|
|
51
|
+
expiresAt: session.expiresAt,
|
|
52
|
+
userId: session.userId,
|
|
53
|
+
email: session.email,
|
|
54
|
+
updatedAt: new Date().toISOString(),
|
|
55
|
+
};
|
|
56
|
+
env.store.write(env.host, stored);
|
|
57
|
+
return ctx.succeed({
|
|
58
|
+
host: env.host,
|
|
59
|
+
status: 'authenticated',
|
|
60
|
+
user: { id: session.userId, email: session.email },
|
|
61
|
+
expiresAt: session.expiresAt,
|
|
62
|
+
credentialsPath: env.store.filePath,
|
|
63
|
+
}, `login: authenticated to ${env.host} as ${(_c = session.email) !== null && _c !== void 0 ? _c : session.userId}.`);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Probe a stored session. Returns null when it cannot be revived, so `login`
|
|
67
|
+
* falls through to a fresh device flow rather than failing — "log in again"
|
|
68
|
+
* is the whole point of the command being run.
|
|
69
|
+
*
|
|
70
|
+
* Only auth failures are absorbed. A network or configuration error still
|
|
71
|
+
* propagates: silently starting a browser login because the host was
|
|
72
|
+
* unreachable would be the wrong recovery, and would hide the real fault.
|
|
73
|
+
*/
|
|
74
|
+
async function tryReuse(env, existing) {
|
|
75
|
+
try {
|
|
76
|
+
const { identity, session } = await resolveIdentity(env, existing);
|
|
77
|
+
return {
|
|
78
|
+
user: { id: identity.id, email: identity.email },
|
|
79
|
+
expiresAt: session.expiresAt,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (isAuthFailure(error)) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function isAuthFailure(error) {
|
|
90
|
+
return error instanceof CliError && error.code === ERROR_CODES.AUTH_REQUIRED;
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=login.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"login.js","sourceRoot":"","sources":["../../src/commands/login.ts"],"names":[],"mappings":"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","sourcesContent":["import type { StoredSession } from '../auth/credential-store';\nimport {\n pollForDeviceTokens,\n startDeviceAuthorization,\n} from '../auth/device-flow';\nimport { createFusionSession, fetchCliAuthConfig } from '../auth/fusion-api';\nimport { resolveAuthEnvironment, resolveIdentity } from '../auth/session';\nimport type { CliContext } from '../context';\nimport { CliError } from '../errors';\nimport { ERROR_CODES } from '../exit-codes';\nimport type { ExitCode } from '../exit-codes';\n\nexport interface LoginArgs {\n host?: string;\n /** Start a new browser login even if a working session is already stored. */\n force: boolean;\n env: Record<string, string | undefined>;\n}\n\n/**\n * `login` — authenticate this machine against a Fusion deployment via the\n * Auth0 device authorization flow.\n *\n * Idempotent: with a stored session that still authenticates, it reports that\n * and exits 0 without opening a browser. `--force` skips the check.\n *\n * The user code and verification URL go to STDERR. They have to be visible\n * even when stdout is a pipe into an agent, and stdout in this CLI carries\n * exactly one JSON object.\n */\nexport async function runLogin(\n ctx: CliContext,\n args: LoginArgs,\n): Promise<ExitCode> {\n const env = resolveAuthEnvironment(ctx, args.host, args.env);\n\n if (!args.force) {\n const existing = env.store.read(env.host);\n\n if (existing) {\n const reusable = await tryReuse(env, existing);\n\n if (reusable) {\n return ctx.succeed(\n {\n host: env.host,\n status: 'already-authenticated',\n user: reusable.user,\n expiresAt: reusable.expiresAt,\n credentialsPath: env.store.filePath,\n },\n `login: already authenticated to ${env.host} as ${\n reusable.user.email ?? reusable.user.id\n }.`,\n );\n }\n\n ctx.log('The stored session has expired. Starting a new login…');\n }\n }\n\n const config = await fetchCliAuthConfig(env.host);\n const authorization = await startDeviceAuthorization(config);\n\n ctx.log('');\n ctx.log(\n ` Open: ${authorization.verificationUriComplete ?? authorization.verificationUri}`,\n );\n ctx.log(` Code: ${authorization.userCode}`);\n\n if (authorization.verificationUriComplete) {\n ctx.log(\n ` (without the pre-filled code: ${authorization.verificationUri})`,\n );\n }\n\n ctx.log('');\n ctx.log('Waiting for approval…');\n\n const tokens = await pollForDeviceTokens(config, authorization);\n const session = await createFusionSession(env.host, tokens.accessToken);\n\n const stored: StoredSession = {\n auth0RefreshToken: tokens.refreshToken,\n accessToken: session.accessToken,\n expiresAt: session.expiresAt,\n userId: session.userId,\n email: session.email,\n updatedAt: new Date().toISOString(),\n };\n\n env.store.write(env.host, stored);\n\n return ctx.succeed(\n {\n host: env.host,\n status: 'authenticated',\n user: { id: session.userId, email: session.email },\n expiresAt: session.expiresAt,\n credentialsPath: env.store.filePath,\n },\n `login: authenticated to ${env.host} as ${session.email ?? session.userId}.`,\n );\n}\n\n/**\n * Probe a stored session. Returns null when it cannot be revived, so `login`\n * falls through to a fresh device flow rather than failing — \"log in again\"\n * is the whole point of the command being run.\n *\n * Only auth failures are absorbed. A network or configuration error still\n * propagates: silently starting a browser login because the host was\n * unreachable would be the wrong recovery, and would hide the real fault.\n */\nasync function tryReuse(\n env: ReturnType<typeof resolveAuthEnvironment>,\n existing: StoredSession,\n): Promise<{\n user: { id: string; email: string | null };\n expiresAt: string;\n} | null> {\n try {\n const { identity, session } = await resolveIdentity(env, existing);\n\n return {\n user: { id: identity.id, email: identity.email },\n expiresAt: session.expiresAt,\n };\n } catch (error) {\n if (isAuthFailure(error)) {\n return null;\n }\n\n throw error;\n }\n}\n\nfunction isAuthFailure(error: unknown): boolean {\n return error instanceof CliError && error.code === ERROR_CODES.AUTH_REQUIRED;\n}\n"]}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CliContext } from '../context.js';
|
|
2
|
+
import type { ExitCode } from '../exit-codes.js';
|
|
3
|
+
export interface LogoutArgs {
|
|
4
|
+
host?: string;
|
|
5
|
+
/** Sign out of every host in the credentials file. */
|
|
6
|
+
all: boolean;
|
|
7
|
+
env: Record<string, string | undefined>;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* `logout` — revoke the stored refresh token at Auth0, then delete it locally.
|
|
11
|
+
*
|
|
12
|
+
* Idempotent: logging out twice exits 0. `cleared: false` in the envelope says
|
|
13
|
+
* there was nothing to remove; it is information, not a failure.
|
|
14
|
+
*
|
|
15
|
+
* Revocation is attempted first but never blocks the local delete. A logout
|
|
16
|
+
* that only forgets the token would leave a rotating credential live for weeks;
|
|
17
|
+
* a logout that REFUSES because the network is down would leave the token on
|
|
18
|
+
* disk, which is worse. So: try to revoke, always delete, report both.
|
|
19
|
+
*/
|
|
20
|
+
export declare function runLogout(ctx: CliContext, args: LogoutArgs): Promise<ExitCode>;
|