@atolljs/cli 0.0.0-stage → 0.1.5
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 +42 -2
- package/dist/cli.js +2270 -0
- package/package.json +34 -4
- package/src/cli.ts +97 -0
- package/src/commands.ts +626 -0
- package/src/files.ts +43 -0
- package/src/io.ts +127 -0
- package/src/parse.ts +53 -0
- package/src/project.ts +132 -0
- package/src/templates.ts +1643 -0
package/src/commands.ts
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join, relative } from 'node:path';
|
|
4
|
+
import type { Io } from './io.ts';
|
|
5
|
+
import { camel, flag, hasFlag, pascal, validName, type ParsedArgs } from './parse.ts';
|
|
6
|
+
import {
|
|
7
|
+
ATOLL_DEPS,
|
|
8
|
+
ISLAND_FRAMEWORKS,
|
|
9
|
+
detectProject,
|
|
10
|
+
missingDeps,
|
|
11
|
+
pmInstallCmd,
|
|
12
|
+
type Framework,
|
|
13
|
+
type Pm,
|
|
14
|
+
type ProjectInfo,
|
|
15
|
+
} from './project.ts';
|
|
16
|
+
import { writeTree, type OutFile } from './files.ts';
|
|
17
|
+
import * as T from './templates.ts';
|
|
18
|
+
|
|
19
|
+
export interface Ctx {
|
|
20
|
+
io: Io;
|
|
21
|
+
cwd: string;
|
|
22
|
+
args: ParsedArgs;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const NODE_HOSTS: readonly Framework[] = ['node', 'nestjs'];
|
|
26
|
+
const hostOf = (fw: Framework): 'browser' | 'node' =>
|
|
27
|
+
NODE_HOSTS.includes(fw) ? 'node' : 'browser';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Install into the detected package manager, honoring the user's answer.
|
|
31
|
+
* The just-written .npmrc carries min-release-age=7 — which also blocks
|
|
32
|
+
* brand-new releases of the packages being installed — so the one-time
|
|
33
|
+
* bootstrap runs with an explicit --min-release-age=0 override and says
|
|
34
|
+
* so. The policy still governs every install after this one.
|
|
35
|
+
*/
|
|
36
|
+
async function offerInstall(
|
|
37
|
+
ctx: Ctx,
|
|
38
|
+
project: ProjectInfo,
|
|
39
|
+
pkgs: readonly string[],
|
|
40
|
+
installAll = false,
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
if (hasFlag(ctx.args, 'no-install')) {
|
|
43
|
+
ctx.io.warn('--no-install — skipping dependency install');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const missing = missingDeps(project.pkg, pkgs);
|
|
47
|
+
if (missing.length === 0 && !installAll) return;
|
|
48
|
+
const cmd = pmInstallCmd(project.pm, installAll ? [] : missing);
|
|
49
|
+
const yes = await ctx.io.confirm(`install dependencies? (${ctx.io.fmt.accent(cmd)})`, true);
|
|
50
|
+
if (!yes) {
|
|
51
|
+
ctx.io.warn(`skipped — run ${ctx.io.fmt.accent(cmd)} when ready`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
ctx.io.print(
|
|
55
|
+
ctx.io.fmt.dim(
|
|
56
|
+
'note: bootstrap install runs with --min-release-age=0 — the new .npmrc policy applies from the next install on',
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
const [bin, ...rest] = cmd.split(' ').filter(Boolean);
|
|
60
|
+
const r = spawnSync(bin, [...rest, '--min-release-age=0'], {
|
|
61
|
+
cwd: project.root ?? ctx.cwd,
|
|
62
|
+
stdio: 'inherit',
|
|
63
|
+
});
|
|
64
|
+
if (r.status !== 0) throw new Error(`install failed — run \`${cmd}\` manually`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* ── atoll init ─────────────────────────────────────────────────────────── */
|
|
68
|
+
|
|
69
|
+
export async function runInit(ctx: Ctx): Promise<number> {
|
|
70
|
+
const project = detectProject(ctx.cwd);
|
|
71
|
+
if (!project.root || !project.pkg) {
|
|
72
|
+
ctx.io.error('no package.json found — run `atoll new <dir>` to scaffold, or cd into a project');
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
const rel = (abs: string) => relative(ctx.cwd, abs).replaceAll('\\', '/');
|
|
76
|
+
ctx.io.print(
|
|
77
|
+
ctx.io.fmt.dim(
|
|
78
|
+
`detected: ${project.framework} · ${project.pm} · root ${project.root}`,
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// Supply-chain baseline — the same .npmrc this repo ships.
|
|
83
|
+
await writeTree(ctx.io, ctx.cwd, [{ path: rel(join(project.root, '.npmrc')), content: T.NPMRC }], {
|
|
84
|
+
force: hasFlag(ctx.args, 'force'),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await offerInstall(ctx, project, ATOLL_DEPS[project.framework]);
|
|
88
|
+
|
|
89
|
+
// A working spine: contract + worker + client, already wired together.
|
|
90
|
+
const atollDir = join(project.srcDir, 'atoll');
|
|
91
|
+
const host = hostOf(project.framework);
|
|
92
|
+
const name = flag(ctx.args, 'name') ?? 'app';
|
|
93
|
+
const files: OutFile[] = [
|
|
94
|
+
{ path: `${rel(atollDir)}/${name}.memory.ts`, content: T.memoryContract(name) },
|
|
95
|
+
{
|
|
96
|
+
path: `${rel(atollDir)}/${name}.worker.ts`,
|
|
97
|
+
content: T.workerEntry(name, {
|
|
98
|
+
host,
|
|
99
|
+
memoryImport: `./${name}.memory`,
|
|
100
|
+
memoryName: `${camel(name)}Memory`,
|
|
101
|
+
demoMemoryWrite: true,
|
|
102
|
+
}),
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
path: `${rel(atollDir)}/${name}.ts`,
|
|
106
|
+
content: T.workerClientFile(name, {
|
|
107
|
+
host,
|
|
108
|
+
memoryImport: `./${name}.memory`,
|
|
109
|
+
memoryName: `${camel(name)}Memory`,
|
|
110
|
+
// src/atoll/x.ts → <root>/dist/x.worker.js
|
|
111
|
+
distUrl: `${relative(atollDir, join(project.root, 'dist')).replaceAll('\\', '/')}/${name}.worker.js`,
|
|
112
|
+
}),
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
await writeTree(ctx.io, ctx.cwd, files, { force: hasFlag(ctx.args, 'force') });
|
|
116
|
+
|
|
117
|
+
ctx.io.print('');
|
|
118
|
+
ctx.io.print(ctx.io.fmt.strong('next steps:'));
|
|
119
|
+
if (host === 'node') {
|
|
120
|
+
ctx.io.print(
|
|
121
|
+
` bundle the worker entry (see the comment in ${name}.ts), then call ${ctx.io.fmt.accent(`${camel(name)}.echo('hi')`)}`,
|
|
122
|
+
);
|
|
123
|
+
} else {
|
|
124
|
+
ctx.io.print(
|
|
125
|
+
` call ${ctx.io.fmt.accent(`${camel(name)}.echo('hi')`)} — the pool spawns lazily on first call`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
ctx.io.print(
|
|
129
|
+
` ${ctx.io.fmt.accent('atoll add worker|memory|island <name>')} grows it · ${ctx.io.fmt.accent('atoll doctor')} checks the setup`,
|
|
130
|
+
);
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* ── atoll add [framework] <kind> [variant] [name] ───────────────────────── */
|
|
135
|
+
|
|
136
|
+
const ADD_KINDS = [
|
|
137
|
+
'worker',
|
|
138
|
+
'memory',
|
|
139
|
+
'island',
|
|
140
|
+
'service',
|
|
141
|
+
'method',
|
|
142
|
+
'module',
|
|
143
|
+
'housed',
|
|
144
|
+
'route',
|
|
145
|
+
'component',
|
|
146
|
+
'instrumentation',
|
|
147
|
+
] as const;
|
|
148
|
+
type AddKind = (typeof ADD_KINDS)[number];
|
|
149
|
+
|
|
150
|
+
/** null = any host; otherwise the only frameworks the kind applies to. */
|
|
151
|
+
const KIND_SCOPE: Record<AddKind, readonly Framework[] | null> = {
|
|
152
|
+
worker: null,
|
|
153
|
+
memory: null,
|
|
154
|
+
island: ISLAND_FRAMEWORKS,
|
|
155
|
+
service: ['nestjs'],
|
|
156
|
+
method: ['nestjs'],
|
|
157
|
+
module: ['nestjs'],
|
|
158
|
+
housed: ['nestjs'],
|
|
159
|
+
route: ['nextjs'],
|
|
160
|
+
component: ['nextjs'],
|
|
161
|
+
instrumentation: ['nextjs'],
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Kinds with named variants — `add island facade counter`,
|
|
166
|
+
* `add nextjs route task digest`. Also settable via --variant; when absent
|
|
167
|
+
* an interactive session picks, a scripted run takes the first listed.
|
|
168
|
+
*/
|
|
169
|
+
const KIND_VARIANTS: Partial<Record<AddKind, readonly string[]>> = {
|
|
170
|
+
island: ['facade', 'mono'],
|
|
171
|
+
route: ['task', 'client'],
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** Positional framework words — `add react island …` overrides detection. */
|
|
175
|
+
const FRAMEWORK_WORDS: readonly Framework[] = [
|
|
176
|
+
'angular',
|
|
177
|
+
'nestjs',
|
|
178
|
+
'nextjs',
|
|
179
|
+
'node',
|
|
180
|
+
'react',
|
|
181
|
+
'solid',
|
|
182
|
+
'svelte',
|
|
183
|
+
'vanilla',
|
|
184
|
+
'vue',
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
async function pickName(ctx: Ctx, kind: string, given?: string): Promise<string | null> {
|
|
188
|
+
const name = given ?? flag(ctx.args, 'name') ?? (await ctx.io.ask(`${kind} name (kebab-case):`, ''));
|
|
189
|
+
if (!name || !validName(name)) {
|
|
190
|
+
ctx.io.error(
|
|
191
|
+
`invalid ${kind} name ${ctx.io.fmt.accent(name)} — lowercase kebab-case, e.g. incidents-report`,
|
|
192
|
+
);
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
return name;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Locate the contract import for a generated worker in `dir`. */
|
|
199
|
+
function resolveMemory(
|
|
200
|
+
ctx: Ctx,
|
|
201
|
+
dir: string,
|
|
202
|
+
name: string,
|
|
203
|
+
): { import_: string; export_: string } | null {
|
|
204
|
+
if (hasFlag(ctx.args, 'no-memory')) return null;
|
|
205
|
+
const want = flag(ctx.args, 'memory') ?? name;
|
|
206
|
+
const direct = join(dir, `${want}.memory.ts`);
|
|
207
|
+
if (existsSync(direct)) {
|
|
208
|
+
return { import_: `./${want}.memory`, export_: `${camel(want)}Memory` };
|
|
209
|
+
}
|
|
210
|
+
// A lone *.memory.ts in the dir is the obvious contract — wire it.
|
|
211
|
+
const candidates = readdirSync(dir, { withFileTypes: true })
|
|
212
|
+
.filter((e) => e.isFile() && e.name.endsWith('.memory.ts'))
|
|
213
|
+
.map((e) => e.name.slice(0, -'.memory.ts'.length));
|
|
214
|
+
const only = candidates.length === 1 ? candidates[0] : null;
|
|
215
|
+
return only ? { import_: `./${only}.memory`, export_: `${camel(only)}Memory` } : null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function runAdd(ctx: Ctx): Promise<number> {
|
|
219
|
+
const project = detectProject(ctx.cwd);
|
|
220
|
+
if (!project.root) {
|
|
221
|
+
ctx.io.error('no package.json found — cd into a project or run `atoll new`');
|
|
222
|
+
return 1;
|
|
223
|
+
}
|
|
224
|
+
const rel = (abs: string) => relative(ctx.cwd, abs).replaceAll('\\', '/');
|
|
225
|
+
|
|
226
|
+
// Grammar: add [framework] <kind> [variant] [name]
|
|
227
|
+
let rest = ctx.args._.slice(1);
|
|
228
|
+
let framework = flag(ctx.args, 'framework') as Framework | undefined;
|
|
229
|
+
if (rest[0] && (FRAMEWORK_WORDS as readonly string[]).includes(rest[0])) {
|
|
230
|
+
framework ??= rest[0] as Framework;
|
|
231
|
+
rest = rest.slice(1);
|
|
232
|
+
}
|
|
233
|
+
framework ??= project.framework;
|
|
234
|
+
|
|
235
|
+
let kind = rest[0] as AddKind | undefined;
|
|
236
|
+
if (!kind) {
|
|
237
|
+
const available = ADD_KINDS.filter(
|
|
238
|
+
(k) => !KIND_SCOPE[k] || KIND_SCOPE[k].includes(framework!),
|
|
239
|
+
);
|
|
240
|
+
kind = ctx.io.interactive
|
|
241
|
+
? ((await ctx.io.select('add what?', available, available[0])) as AddKind)
|
|
242
|
+
: undefined;
|
|
243
|
+
if (!kind) {
|
|
244
|
+
ctx.io.error(`expected a kind — ${ADD_KINDS.join(' | ')}`);
|
|
245
|
+
return 1;
|
|
246
|
+
}
|
|
247
|
+
rest = [kind, ...rest.slice(1)];
|
|
248
|
+
}
|
|
249
|
+
if (!(ADD_KINDS as readonly string[]).includes(kind)) {
|
|
250
|
+
ctx.io.error(`unknown add target ${ctx.io.fmt.accent(kind)} — expected ${ADD_KINDS.join(' | ')}`);
|
|
251
|
+
return 1;
|
|
252
|
+
}
|
|
253
|
+
const scope = KIND_SCOPE[kind];
|
|
254
|
+
if (scope && !scope.includes(framework)) {
|
|
255
|
+
ctx.io.error(
|
|
256
|
+
`${kind} is for ${scope.join('/')} projects — detected "${framework}". Pass a framework word (e.g. \`atoll add nestjs ${kind} …\`) to override.`,
|
|
257
|
+
);
|
|
258
|
+
return 1;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Variant — positional when it matches, --variant flag, else a pick.
|
|
262
|
+
const variants = KIND_VARIANTS[kind];
|
|
263
|
+
let variant: string | undefined;
|
|
264
|
+
let nameArg: string | undefined;
|
|
265
|
+
if (variants) {
|
|
266
|
+
if (rest[1] && variants.includes(rest[1])) {
|
|
267
|
+
variant = rest[1];
|
|
268
|
+
nameArg = rest[2];
|
|
269
|
+
} else {
|
|
270
|
+
nameArg = rest[1];
|
|
271
|
+
}
|
|
272
|
+
variant = flag(ctx.args, 'variant') ?? variant;
|
|
273
|
+
if (variant && !variants.includes(variant)) {
|
|
274
|
+
ctx.io.error(`unknown ${kind} variant ${ctx.io.fmt.accent(variant)} — pick ${variants.join(' | ')}`);
|
|
275
|
+
return 1;
|
|
276
|
+
}
|
|
277
|
+
variant ??= ctx.io.interactive
|
|
278
|
+
? await ctx.io.select(`${kind} variant:`, variants, variants[0])
|
|
279
|
+
: variants[0];
|
|
280
|
+
} else {
|
|
281
|
+
nameArg = rest[1];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const name = await pickName(ctx, kind, nameArg);
|
|
285
|
+
if (!name) return 1;
|
|
286
|
+
const force = { force: hasFlag(ctx.args, 'force') };
|
|
287
|
+
const atollDir = flag(ctx.args, 'dir') ?? rel(join(project.srcDir, 'atoll'));
|
|
288
|
+
const c = camel(name);
|
|
289
|
+
const P = pascal(name);
|
|
290
|
+
|
|
291
|
+
switch (kind) {
|
|
292
|
+
case 'memory': {
|
|
293
|
+
await writeTree(
|
|
294
|
+
ctx.io,
|
|
295
|
+
ctx.cwd,
|
|
296
|
+
[{ path: `${atollDir}/${name}.memory.ts`, content: T.memoryContract(name) }],
|
|
297
|
+
force,
|
|
298
|
+
);
|
|
299
|
+
ctx.io.print(
|
|
300
|
+
`\nbind it: pass ${ctx.io.fmt.accent(`${c}Memory`)} as ${ctx.io.fmt.accent('sharedMemory')} to defineWorker/connectWorker,`,
|
|
301
|
+
);
|
|
302
|
+
ctx.io.print(
|
|
303
|
+
`or ${ctx.io.fmt.accent(`atoll add worker ${name}`)} in the same directory wires it automatically.`,
|
|
304
|
+
);
|
|
305
|
+
return 0;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
case 'worker': {
|
|
309
|
+
const dir = atollDir;
|
|
310
|
+
mkdirSync(join(ctx.cwd, dir), { recursive: true });
|
|
311
|
+
const host = hostOf(framework);
|
|
312
|
+
const mem = resolveMemory(ctx, join(ctx.cwd, dir), name);
|
|
313
|
+
const opts = {
|
|
314
|
+
host,
|
|
315
|
+
memoryImport: mem?.import_ ?? null,
|
|
316
|
+
memoryName: mem?.export_,
|
|
317
|
+
distUrl: `${relative(join(ctx.cwd, dir), join(project.root, 'dist')).replaceAll('\\', '/')}/${name}.worker.js`,
|
|
318
|
+
};
|
|
319
|
+
const bindings = T.workerBindingsFile(name, framework, dir, mem);
|
|
320
|
+
await writeTree(
|
|
321
|
+
ctx.io,
|
|
322
|
+
ctx.cwd,
|
|
323
|
+
[
|
|
324
|
+
{ path: `${dir}/${name}.worker.ts`, content: T.workerEntry(name, opts) },
|
|
325
|
+
{ path: `${dir}/${name}.ts`, content: T.workerClientFile(name, opts) },
|
|
326
|
+
...(bindings ? [bindings] : []),
|
|
327
|
+
],
|
|
328
|
+
force,
|
|
329
|
+
);
|
|
330
|
+
ctx.io.print('');
|
|
331
|
+
if (host === 'node') {
|
|
332
|
+
ctx.io.print(`bundle before spawning — add to package.json scripts:`);
|
|
333
|
+
ctx.io.print(
|
|
334
|
+
ctx.io.fmt.accent(
|
|
335
|
+
` "bundle:${name}": "esbuild ${dir}/${name}.worker.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/${name}.worker.js"`,
|
|
336
|
+
),
|
|
337
|
+
);
|
|
338
|
+
} else {
|
|
339
|
+
ctx.io.print(
|
|
340
|
+
`call ${ctx.io.fmt.accent(`${c}.echo('hi')`)} — the pool spawns lazily on first call`,
|
|
341
|
+
);
|
|
342
|
+
if (bindings) ctx.io.print(`framework bindings: ${ctx.io.fmt.accent(bindings.path)}`);
|
|
343
|
+
}
|
|
344
|
+
return 0;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
case 'island': {
|
|
348
|
+
const dir = flag(ctx.args, 'dir') ?? rel(join(project.srcDir, 'islands'));
|
|
349
|
+
const files =
|
|
350
|
+
variant === 'facade'
|
|
351
|
+
? T.islandFacadeFiles(name, framework, dir)
|
|
352
|
+
: T.islandFiles(name, framework, dir);
|
|
353
|
+
await writeTree(ctx.io, ctx.cwd, files, force);
|
|
354
|
+
const usage =
|
|
355
|
+
variant === 'facade' ? T.islandFacadeUsage(name, framework) : T.islandUsage(name, framework);
|
|
356
|
+
if (usage) {
|
|
357
|
+
ctx.io.print(ctx.io.fmt.strong('\nshell side:'));
|
|
358
|
+
ctx.io.print(usage);
|
|
359
|
+
}
|
|
360
|
+
return 0;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
case 'service': {
|
|
364
|
+
const dir = atollDir;
|
|
365
|
+
mkdirSync(join(ctx.cwd, dir), { recursive: true });
|
|
366
|
+
const mem = resolveMemory(ctx, join(ctx.cwd, dir), name);
|
|
367
|
+
await writeTree(ctx.io, ctx.cwd, T.nestjsServiceFiles(name, dir, mem), force);
|
|
368
|
+
ctx.io.print('');
|
|
369
|
+
ctx.io.print(
|
|
370
|
+
`inject ${ctx.io.fmt.accent(`${P}Service`)} anywhere — every method dispatches to the ${ctx.io.fmt.accent(`'${name}'`)} pool.`,
|
|
371
|
+
);
|
|
372
|
+
return 0;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
case 'method': {
|
|
376
|
+
const dir = atollDir;
|
|
377
|
+
mkdirSync(join(ctx.cwd, dir), { recursive: true });
|
|
378
|
+
await writeTree(ctx.io, ctx.cwd, T.nestjsMethodFiles(name, dir), force);
|
|
379
|
+
ctx.io.print('');
|
|
380
|
+
ctx.io.print(
|
|
381
|
+
`needs a '${name}' pool — ${ctx.io.fmt.accent(`atoll add nestjs module ${name}`)} registers one,`,
|
|
382
|
+
);
|
|
383
|
+
ctx.io.print(`and the worker entry must runAtollWorker a module providing ${P}Service.`);
|
|
384
|
+
return 0;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
case 'module': {
|
|
388
|
+
const dir = atollDir;
|
|
389
|
+
mkdirSync(join(ctx.cwd, dir), { recursive: true });
|
|
390
|
+
const mem = resolveMemory(ctx, join(ctx.cwd, dir), name);
|
|
391
|
+
await writeTree(ctx.io, ctx.cwd, T.nestjsModuleFiles(name, dir, { mem }), force);
|
|
392
|
+
ctx.io.print('');
|
|
393
|
+
ctx.io.print(
|
|
394
|
+
`import ${ctx.io.fmt.accent(`${P}Module`)} into AppModule — then ${ctx.io.fmt.accent(`atoll add nestjs service ${name}`)} adds the facade.`,
|
|
395
|
+
);
|
|
396
|
+
return 0;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
case 'housed': {
|
|
400
|
+
const dir = atollDir;
|
|
401
|
+
mkdirSync(join(ctx.cwd, dir), { recursive: true });
|
|
402
|
+
await writeTree(ctx.io, ctx.cwd, T.nestjsHousedFiles(name, dir), force);
|
|
403
|
+
ctx.io.print('');
|
|
404
|
+
ctx.io.print(ctx.io.fmt.strong('wire the gateway:'));
|
|
405
|
+
ctx.io.print(T.nestjsHousedWiring(name));
|
|
406
|
+
ctx.io.print(ctx.io.fmt.dim(`routes live only in workers — GET /api/${name}/whoami`));
|
|
407
|
+
return 0;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
case 'route': {
|
|
411
|
+
const dir = flag(ctx.args, 'dir') ?? rel(join(project.srcDir, 'app/api', name));
|
|
412
|
+
await writeTree(
|
|
413
|
+
ctx.io,
|
|
414
|
+
ctx.cwd,
|
|
415
|
+
T.nextjsRouteFiles(name, dir, variant as 'task' | 'client'),
|
|
416
|
+
force,
|
|
417
|
+
);
|
|
418
|
+
ctx.io.print('');
|
|
419
|
+
ctx.io.print(
|
|
420
|
+
`${ctx.io.fmt.accent(`POST /api/${name}`)} dispatches into the pool · ${ctx.io.fmt.accent(`GET /api/${name}`)} reads shared memory.`,
|
|
421
|
+
);
|
|
422
|
+
ctx.io.print(
|
|
423
|
+
ctx.io.fmt.dim(`warm it at boot: atoll add nextjs instrumentation ${name}`),
|
|
424
|
+
);
|
|
425
|
+
return 0;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
case 'component': {
|
|
429
|
+
const dir = atollDir;
|
|
430
|
+
mkdirSync(join(ctx.cwd, dir), { recursive: true });
|
|
431
|
+
const mem = resolveMemory(ctx, join(ctx.cwd, dir), name);
|
|
432
|
+
await writeTree(ctx.io, ctx.cwd, T.nextjsComponentFiles(name, dir, mem), force);
|
|
433
|
+
ctx.io.print('');
|
|
434
|
+
ctx.io.print(
|
|
435
|
+
`expects the ${ctx.io.fmt.accent(`./${name}`)} client + contract beside it — ${ctx.io.fmt.accent(`atoll add nextjs worker ${name}`)} first.`,
|
|
436
|
+
);
|
|
437
|
+
return 0;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
case 'instrumentation': {
|
|
441
|
+
await writeTree(
|
|
442
|
+
ctx.io,
|
|
443
|
+
ctx.cwd,
|
|
444
|
+
[T.nextjsInstrumentationFile(name, rel(project.srcDir))],
|
|
445
|
+
force,
|
|
446
|
+
);
|
|
447
|
+
ctx.io.print(
|
|
448
|
+
`\npools warm at boot — extend register() as more routes appear.`,
|
|
449
|
+
);
|
|
450
|
+
return 0;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
default:
|
|
454
|
+
ctx.io.error(`unhandled add kind ${kind}`);
|
|
455
|
+
return 1;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/* ── atoll new <dir> ────────────────────────────────────────────────────── */
|
|
460
|
+
|
|
461
|
+
const NEW_FRAMEWORKS = ['react', 'vue', 'solid', 'svelte', 'node'] as const;
|
|
462
|
+
|
|
463
|
+
export async function runCreate(ctx: Ctx): Promise<number> {
|
|
464
|
+
let dirName = ctx.args._[1] ?? flag(ctx.args, 'dir');
|
|
465
|
+
if (!dirName) dirName = await ctx.io.ask('project directory:', 'my-atoll-app');
|
|
466
|
+
if (!validName(dirName)) {
|
|
467
|
+
ctx.io.error(`invalid directory name ${ctx.io.fmt.accent(dirName)}`);
|
|
468
|
+
return 1;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const fwFlag = flag(ctx.args, 'framework') as Framework | undefined;
|
|
472
|
+
const fw =
|
|
473
|
+
fwFlag ??
|
|
474
|
+
(await ctx.io.select('framework:', NEW_FRAMEWORKS, 'react'));
|
|
475
|
+
if (fw === 'angular') {
|
|
476
|
+
ctx.io.error('angular apps scaffold through the CLI: `ng new <name>`, then `atoll init` + `atoll add island`');
|
|
477
|
+
return 1;
|
|
478
|
+
}
|
|
479
|
+
if (!(NEW_FRAMEWORKS as readonly string[]).includes(fw)) {
|
|
480
|
+
ctx.io.error(`unknown framework "${fw}" — pick from ${NEW_FRAMEWORKS.join(', ')}`);
|
|
481
|
+
return 1;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const target = join(ctx.cwd, dirName);
|
|
485
|
+
if (existsSync(target) && readdirSync(target).length > 0) {
|
|
486
|
+
ctx.io.error(`${dirName}/ exists and is not empty`);
|
|
487
|
+
return 1;
|
|
488
|
+
}
|
|
489
|
+
mkdirSync(target, { recursive: true });
|
|
490
|
+
|
|
491
|
+
const files =
|
|
492
|
+
fw === 'node' ? T.nodeScaffoldFiles(dirName) : T.scaffoldFiles(dirName, fw);
|
|
493
|
+
await writeTree(ctx.io, target, files, { force: true });
|
|
494
|
+
|
|
495
|
+
const written = JSON.parse(readFileSync(join(target, 'package.json'), 'utf8'));
|
|
496
|
+
const allDeps = [
|
|
497
|
+
...Object.keys(written.dependencies ?? {}),
|
|
498
|
+
...Object.keys(written.devDependencies ?? {}),
|
|
499
|
+
];
|
|
500
|
+
const pm = (flag(ctx.args, 'pm') as Pm | undefined) ?? 'npm';
|
|
501
|
+
await offerInstall(
|
|
502
|
+
{ ...ctx, cwd: target },
|
|
503
|
+
{ root: target, pkg: {}, framework: 'vanilla', pm, srcDir: target },
|
|
504
|
+
allDeps,
|
|
505
|
+
true, // fresh scaffold — the deps are declared, nothing is installed yet
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
ctx.io.print('');
|
|
509
|
+
ctx.io.print(`done — ${ctx.io.fmt.accent(`cd ${dirName} && ${pm} run dev`)}`);
|
|
510
|
+
return 0;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/* ── atoll doctor ───────────────────────────────────────────────────────── */
|
|
514
|
+
|
|
515
|
+
interface Check {
|
|
516
|
+
label: string;
|
|
517
|
+
run(): { level: 'pass' | 'warn' | 'fail'; detail?: string };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function runDoctor(ctx: Ctx): number {
|
|
521
|
+
const project = detectProject(ctx.cwd);
|
|
522
|
+
const fix = hasFlag(ctx.args, 'fix');
|
|
523
|
+
const checks: Check[] = [
|
|
524
|
+
{
|
|
525
|
+
label: 'node >= 22.18 (type-stripped CLI) / >= 20 (runtime)',
|
|
526
|
+
run: () => {
|
|
527
|
+
const [maj, min] = process.versions.node.split('.').map(Number);
|
|
528
|
+
return maj > 22 || (maj === 22 && min >= 18)
|
|
529
|
+
? { level: 'pass', detail: `v${process.versions.node}` }
|
|
530
|
+
: maj >= 20
|
|
531
|
+
? { level: 'warn', detail: `v${process.versions.node} — runtime OK; the CLI bin needs >=22.18` }
|
|
532
|
+
: { level: 'fail', detail: `v${process.versions.node}` };
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
label: 'package.json',
|
|
537
|
+
run: () =>
|
|
538
|
+
project.pkg
|
|
539
|
+
? { level: 'pass', detail: `${project.root}/package.json` }
|
|
540
|
+
: { level: 'fail', detail: 'not found — run `atoll new` or cd into a project' },
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
label: '@atolljs/* dependencies',
|
|
544
|
+
run: () => {
|
|
545
|
+
const deps = { ...project.pkg?.dependencies, ...project.pkg?.devDependencies };
|
|
546
|
+
const found = Object.keys(deps).filter((d) => d.startsWith('@atolljs/'));
|
|
547
|
+
return found.length
|
|
548
|
+
? { level: 'pass', detail: found.join(', ') }
|
|
549
|
+
: { level: 'warn', detail: 'none — `atoll init` installs what the framework needs' };
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
label: '.npmrc supply-chain policy (min-release-age)',
|
|
554
|
+
run: () => {
|
|
555
|
+
const p = join(project.root ?? ctx.cwd, '.npmrc');
|
|
556
|
+
const has = existsSync(p) && readFileSync(p, 'utf8').includes('min-release-age');
|
|
557
|
+
if (has) return { level: 'pass' };
|
|
558
|
+
if (fix) {
|
|
559
|
+
writeFileSync(p, T.NPMRC);
|
|
560
|
+
return { level: 'pass', detail: 'written (--fix)' };
|
|
561
|
+
}
|
|
562
|
+
return { level: 'warn', detail: 'missing — `atoll doctor --fix` writes the hardened .npmrc' };
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
{
|
|
566
|
+
label: 'lockfile present',
|
|
567
|
+
run: () =>
|
|
568
|
+
existsSync(join(project.root ?? ctx.cwd, 'package-lock.json')) ||
|
|
569
|
+
existsSync(join(project.root ?? ctx.cwd, 'pnpm-lock.yaml')) ||
|
|
570
|
+
existsSync(join(project.root ?? ctx.cwd, 'yarn.lock'))
|
|
571
|
+
? { level: 'pass' }
|
|
572
|
+
: { level: 'warn', detail: 'no lockfile — installs are not reproducible' },
|
|
573
|
+
},
|
|
574
|
+
{
|
|
575
|
+
label: 'worker entries bundler-detectable',
|
|
576
|
+
run: () => {
|
|
577
|
+
if (!project.srcDir || !existsSync(project.srcDir)) return { level: 'pass' };
|
|
578
|
+
let scanned = 0;
|
|
579
|
+
let bad = 0;
|
|
580
|
+
const walk = (d: string) => {
|
|
581
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
582
|
+
const p = join(d, e.name);
|
|
583
|
+
if (e.isDirectory() && e.name !== 'node_modules') walk(p);
|
|
584
|
+
else if (/\.(ts|tsx|js|mjs)$/.test(e.name)) {
|
|
585
|
+
scanned++;
|
|
586
|
+
const src = readFileSync(p, 'utf8');
|
|
587
|
+
const entries = src.match(/new Worker\s*\(/g) ?? [];
|
|
588
|
+
const detectable = src.match(/new Worker\s*\(\s*new URL\s*\(/g) ?? [];
|
|
589
|
+
bad += entries.length - detectable.length;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
walk(project.srcDir);
|
|
594
|
+
return bad
|
|
595
|
+
? { level: 'fail', detail: `${bad} new Worker() call(s) without \`new URL(..., import.meta.url)\` — bundlers can't see them` }
|
|
596
|
+
: { level: 'pass', detail: `${scanned} files scanned` };
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
label: 'COOP/COEP headers (SharedArrayBuffer gate)',
|
|
601
|
+
run: () => {
|
|
602
|
+
if (NODE_HOSTS.includes(project.framework)) {
|
|
603
|
+
return { level: 'pass', detail: 'n/a — node runtime' };
|
|
604
|
+
}
|
|
605
|
+
const vite = ['vite.config.ts', 'vite.config.js', 'vite.config.mts']
|
|
606
|
+
.map((f) => join(project.root ?? ctx.cwd, f))
|
|
607
|
+
.find(existsSync);
|
|
608
|
+
if (!vite) return { level: 'warn', detail: 'no vite config found — verify headers on your server' };
|
|
609
|
+
const src = readFileSync(vite, 'utf8');
|
|
610
|
+
return src.includes('Cross-Origin-Opener-Policy') && src.includes('Cross-Origin-Embedder-Policy')
|
|
611
|
+
? { level: 'pass' }
|
|
612
|
+
: { level: 'warn', detail: `${vite.split(/[\\/]/).pop()} lacks COOP/COEP — shared memory will be gated in dev` };
|
|
613
|
+
},
|
|
614
|
+
},
|
|
615
|
+
];
|
|
616
|
+
|
|
617
|
+
let fails = 0;
|
|
618
|
+
for (const c of checks) {
|
|
619
|
+
const r = c.run();
|
|
620
|
+
const mark = r.level === 'pass' ? 'ok' : r.level === 'warn' ? 'warn' : 'error';
|
|
621
|
+
ctx.io[mark](`${c.label}${r.detail ? ctx.io.fmt.dim(` — ${r.detail}`) : ''}`);
|
|
622
|
+
if (r.level === 'fail') fails++;
|
|
623
|
+
}
|
|
624
|
+
return fails ? 1 : 0;
|
|
625
|
+
}
|
|
626
|
+
|
package/src/files.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, normalize } from 'node:path';
|
|
3
|
+
import type { Io } from './io.ts';
|
|
4
|
+
|
|
5
|
+
export interface OutFile {
|
|
6
|
+
/** Path relative to the command's cwd. */
|
|
7
|
+
path: string;
|
|
8
|
+
content: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Write a set of files under `cwd`. Existing files are never silently
|
|
13
|
+
* clobbered — `--force` overwrites, interactive sessions get a prompt, and
|
|
14
|
+
* non-interactive runs skip with a warning.
|
|
15
|
+
*/
|
|
16
|
+
export async function writeTree(
|
|
17
|
+
io: Io,
|
|
18
|
+
cwd: string,
|
|
19
|
+
files: readonly OutFile[],
|
|
20
|
+
opts: { force?: boolean } = {},
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
for (const f of files) {
|
|
23
|
+
const abs = normalize(join(cwd, f.path));
|
|
24
|
+
if (!abs.startsWith(normalize(cwd))) {
|
|
25
|
+
io.error(`refusing to write outside the project: ${io.fmt.accent(f.path)}`);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (existsSync(abs) && !opts.force) {
|
|
29
|
+
const overwrite = io.interactive
|
|
30
|
+
? await io.confirm(`${io.fmt.accent(f.path)} exists — overwrite?`, false)
|
|
31
|
+
: false;
|
|
32
|
+
if (!overwrite) {
|
|
33
|
+
io.warn(
|
|
34
|
+
`skipped ${io.fmt.accent(f.path)} (exists — pass ${io.fmt.accent('--force')} to overwrite)`,
|
|
35
|
+
);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
40
|
+
writeFileSync(abs, f.content);
|
|
41
|
+
io.ok(`created ${io.fmt.accent(f.path)}`);
|
|
42
|
+
}
|
|
43
|
+
}
|