create-packkit 2.9.0 → 3.0.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/README.md +20 -2
- package/package.json +17 -3
- package/src/core/index.js +41 -7
- package/src/core/options.js +44 -22
- package/src/core/render.js +6 -0
- package/src/embedded/contract.js +64 -0
- package/src/embedded/index.js +451 -0
- package/src/embedded/paths.js +88 -0
- package/src/embedded/pkg-merge.js +89 -0
- package/src/embedded/writer.js +151 -0
- package/src/index.js +12 -0
- package/types/core.d.ts +96 -0
- package/types/embedded.d.ts +92 -0
- package/types/index.d.ts +6 -0
- package/types/writer.d.ts +25 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Controlled filesystem output for a GeneratedProject.
|
|
2
|
+
//
|
|
3
|
+
// This is the only place the embedded API touches disk, and it does nothing
|
|
4
|
+
// else: no install, no git, no lifecycle scripts, no command execution. Every
|
|
5
|
+
// path is validated again here — not just when the project was built — and the
|
|
6
|
+
// real on-disk path is checked for symlinks, so a project that reached this
|
|
7
|
+
// boundary from an untrusted source still can't escape the destination.
|
|
8
|
+
|
|
9
|
+
import { mkdir, writeFile, stat, lstat } from 'node:fs/promises';
|
|
10
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
11
|
+
import { validateRelativePath } from './paths.js';
|
|
12
|
+
|
|
13
|
+
export class PackkitWriteError extends Error {
|
|
14
|
+
constructor(message, { code, path, destination, cause } = {}) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'PackkitWriteError';
|
|
17
|
+
this.code = code;
|
|
18
|
+
if (path !== undefined) this.path = path;
|
|
19
|
+
if (destination !== undefined) this.destination = destination;
|
|
20
|
+
if (cause !== undefined) this.cause = cause;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Write a project's files under `destination`. Returns a WriteResult; never
|
|
26
|
+
* installs, inits git, or runs anything. Validates and preflights everything
|
|
27
|
+
* before the first write, so a bad file map or a policy collision fails cleanly
|
|
28
|
+
* rather than leaving a half-written project.
|
|
29
|
+
*
|
|
30
|
+
* @param {{ project: object, destination: string, collisionPolicy?: 'error'|'skip'|'overwrite' }} input
|
|
31
|
+
*/
|
|
32
|
+
export async function writeGeneratedProject(input) {
|
|
33
|
+
const { project, destination, collisionPolicy = 'error' } = input || {};
|
|
34
|
+
if (!project || typeof project !== 'object' || !project.files) {
|
|
35
|
+
throw new TypeError('writeGeneratedProject needs a project with a files map.');
|
|
36
|
+
}
|
|
37
|
+
if (typeof destination !== 'string' || destination.length === 0) {
|
|
38
|
+
throw new TypeError('A destination path is required.');
|
|
39
|
+
}
|
|
40
|
+
if (!['error', 'skip', 'overwrite'].includes(collisionPolicy)) {
|
|
41
|
+
throw new TypeError(`Unknown collisionPolicy "${collisionPolicy}".`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const root = resolve(destination);
|
|
45
|
+
const prefix = root.endsWith(sep) ? root : root + sep;
|
|
46
|
+
|
|
47
|
+
// A symlinked destination could redirect the whole write outside where the
|
|
48
|
+
// caller thinks it's going. Reject it — a host that truly wants this can
|
|
49
|
+
// resolve the real path itself before calling.
|
|
50
|
+
await assertNotSymlink(root, false, root, root);
|
|
51
|
+
|
|
52
|
+
// 1) Lexical validation: any traversal/absolute path means we write nothing.
|
|
53
|
+
const planned = [];
|
|
54
|
+
for (const [path, contents] of Object.entries(project.files)) {
|
|
55
|
+
const res = validateRelativePath(path);
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
throw new PackkitWriteError(`Refusing to write invalid path "${path}": ${res.message}`, { code: res.code, path, destination: root });
|
|
58
|
+
}
|
|
59
|
+
const target = join(root, res.normalized);
|
|
60
|
+
if (target !== root && !target.startsWith(prefix)) {
|
|
61
|
+
throw new PackkitWriteError(`Refusing to write outside the destination: "${path}"`, { code: 'PATH_ESCAPE', path, destination: root });
|
|
62
|
+
}
|
|
63
|
+
planned.push({ path: res.normalized, target, contents });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 2) Symlink + collision preflight against the real filesystem, before any
|
|
67
|
+
// write. Under the 'error' policy, one existing target aborts the whole
|
|
68
|
+
// operation with every collision listed — no partial output.
|
|
69
|
+
const collisions = [];
|
|
70
|
+
for (const { path, target } of planned) {
|
|
71
|
+
await assertNoSymlinkComponents(root, target, path);
|
|
72
|
+
if (await exists(target)) {
|
|
73
|
+
if (collisionPolicy === 'error') collisions.push(path);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (collisions.length) {
|
|
77
|
+
throw new PackkitWriteError(
|
|
78
|
+
`Refusing to overwrite existing file(s): ${collisions.join(', ')}`,
|
|
79
|
+
{ code: 'FILE_EXISTS', destination: root },
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 3) Write.
|
|
84
|
+
const writtenFiles = [];
|
|
85
|
+
const skippedFiles = [];
|
|
86
|
+
const diagnostics = [];
|
|
87
|
+
for (const { path, target, contents } of planned) {
|
|
88
|
+
try {
|
|
89
|
+
if ((collisionPolicy === 'skip') && (await exists(target))) {
|
|
90
|
+
skippedFiles.push(path);
|
|
91
|
+
diagnostics.push({ severity: 'info', code: 'FILE_SKIPPED', field: path, message: `"${path}" already existed and was left in place.`, source: 'writer' });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
await mkdir(dirname(target), { recursive: true });
|
|
95
|
+
await writeFile(target, contents);
|
|
96
|
+
writtenFiles.push(path);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
// A real filesystem failure on one file: record it, keep going, so the
|
|
99
|
+
// caller sees exactly what landed and what didn't.
|
|
100
|
+
diagnostics.push({ severity: 'error', code: 'WRITE_FAILED', field: path, message: `Could not write "${path}": ${err.message}`, source: 'writer' });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { destination: root, writtenFiles, skippedFiles, diagnostics };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Reject any existing symlink among the path components from root to target,
|
|
108
|
+
// including the target itself — a lexical containment check can't catch
|
|
109
|
+
// `dest/link -> /elsewhere`, but following real inodes can. Writing through an
|
|
110
|
+
// existing symlink (even the final file, under 'overwrite') would escape.
|
|
111
|
+
async function assertNoSymlinkComponents(root, target, path) {
|
|
112
|
+
const rel = target.slice(root.length + 1);
|
|
113
|
+
if (!rel) return;
|
|
114
|
+
const segments = rel.split(sep);
|
|
115
|
+
let current = root;
|
|
116
|
+
for (let i = 0; i < segments.length; i++) {
|
|
117
|
+
current = join(current, segments[i]);
|
|
118
|
+
const isFinal = i === segments.length - 1;
|
|
119
|
+
await assertNotSymlink(current, isFinal, path, root);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function assertNotSymlink(component, isFinal, path, destination) {
|
|
124
|
+
try {
|
|
125
|
+
const info = await lstat(component);
|
|
126
|
+
if (info.isSymbolicLink()) {
|
|
127
|
+
throw new PackkitWriteError(`Refusing to write through a symbolic link: "${component}"`, { code: 'SYMLINK_PATH', path, destination });
|
|
128
|
+
}
|
|
129
|
+
// Intermediate components must be directories; the final one may already
|
|
130
|
+
// exist as a regular file (collision handling decides what to do with it).
|
|
131
|
+
if (!info.isDirectory() && !isFinal) {
|
|
132
|
+
throw new PackkitWriteError(`A parent path component is not a directory: "${component}"`, { code: 'PARENT_NOT_DIRECTORY', path, destination });
|
|
133
|
+
}
|
|
134
|
+
} catch (err) {
|
|
135
|
+
if (err instanceof PackkitWriteError) throw err;
|
|
136
|
+
if (err.code === 'ENOENT') return; // doesn't exist yet; nothing to follow
|
|
137
|
+
throw new PackkitWriteError(`Could not inspect "${component}": ${err.message}`, { code: 'STAT_FAILED', path, destination, cause: err });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Only ENOENT means "not there" — permission and I/O errors are real problems
|
|
142
|
+
// and must surface, not be silently read as a missing file.
|
|
143
|
+
async function exists(p) {
|
|
144
|
+
try {
|
|
145
|
+
await stat(p);
|
|
146
|
+
return true;
|
|
147
|
+
} catch (err) {
|
|
148
|
+
if (err.code === 'ENOENT') return false;
|
|
149
|
+
throw new PackkitWriteError(`Could not stat "${p}": ${err.message}`, { code: 'STAT_FAILED', cause: err });
|
|
150
|
+
}
|
|
151
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Package root. Re-exports the pure generation core plus the Node embedded API,
|
|
2
|
+
// so `import { generate, createProject, writeGeneratedProject } from 'create-packkit'`
|
|
3
|
+
// works for host applications.
|
|
4
|
+
//
|
|
5
|
+
// The browser configurator does NOT import this file — it bundles
|
|
6
|
+
// src/core/index.js directly (see build:web) — so pulling the Node-only
|
|
7
|
+
// embedded modules in here doesn't affect browser compatibility of the core.
|
|
8
|
+
// Consumers who want the core in isolation can import 'create-packkit/core'.
|
|
9
|
+
|
|
10
|
+
export * from './core/index.js';
|
|
11
|
+
export * from './embedded/index.js';
|
|
12
|
+
export { writeGeneratedProject, PackkitWriteError } from './embedded/writer.js';
|
package/types/core.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Public types for the pure generation core.
|
|
2
|
+
|
|
3
|
+
export type Language = 'ts' | 'js';
|
|
4
|
+
export type ModuleFormat = 'esm' | 'dual' | 'cjs';
|
|
5
|
+
export type Framework = 'none' | 'react' | 'vue' | 'svelte';
|
|
6
|
+
export type Target = 'library' | 'cli' | 'service' | 'app';
|
|
7
|
+
export type Bundler = 'tsup' | 'tsdown' | 'unbuild' | 'rollup' | 'none';
|
|
8
|
+
export type TestRunner = 'vitest' | 'jest' | 'node' | 'none';
|
|
9
|
+
export type Linter = 'eslint-prettier' | 'biome' | 'oxlint' | 'none';
|
|
10
|
+
export type ReleaseTool = 'changesets' | 'release-it' | 'np' | 'none';
|
|
11
|
+
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
|
|
12
|
+
|
|
13
|
+
/** User-facing configuration. All fields optional on input; a preset or the
|
|
14
|
+
* defaults fill the rest. */
|
|
15
|
+
export interface PackkitConfig {
|
|
16
|
+
name?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
author?: string;
|
|
19
|
+
keywords?: string;
|
|
20
|
+
repo?: string;
|
|
21
|
+
language?: Language;
|
|
22
|
+
moduleFormat?: ModuleFormat;
|
|
23
|
+
framework?: Framework;
|
|
24
|
+
target?: Target[];
|
|
25
|
+
serviceFramework?: 'hono' | 'fastify' | 'express';
|
|
26
|
+
monorepo?: boolean;
|
|
27
|
+
monorepoLayout?: 'libraries' | 'fullstack';
|
|
28
|
+
packageManager?: PackageManager;
|
|
29
|
+
nodeVersion?: string;
|
|
30
|
+
bundler?: Bundler;
|
|
31
|
+
minify?: boolean;
|
|
32
|
+
test?: TestRunner;
|
|
33
|
+
coverage?: boolean;
|
|
34
|
+
storybook?: boolean;
|
|
35
|
+
e2e?: boolean;
|
|
36
|
+
sourcemaps?: boolean;
|
|
37
|
+
env?: boolean;
|
|
38
|
+
canary?: boolean;
|
|
39
|
+
pkgChecks?: boolean;
|
|
40
|
+
knip?: boolean;
|
|
41
|
+
sizeLimit?: boolean;
|
|
42
|
+
doctor?: boolean;
|
|
43
|
+
lint?: Linter;
|
|
44
|
+
gitHooks?: 'simple-git-hooks' | 'husky' | 'lefthook' | 'none';
|
|
45
|
+
release?: ReleaseTool;
|
|
46
|
+
jsr?: boolean;
|
|
47
|
+
workflows?: string[];
|
|
48
|
+
deps?: 'renovate' | 'dependabot' | 'none';
|
|
49
|
+
license?: string;
|
|
50
|
+
community?: boolean;
|
|
51
|
+
agents?: boolean;
|
|
52
|
+
vscode?: boolean;
|
|
53
|
+
editorconfig?: boolean;
|
|
54
|
+
gitInit?: boolean;
|
|
55
|
+
install?: boolean;
|
|
56
|
+
[key: string]: unknown;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The config after normalization: every field resolved, plus derived flags. */
|
|
60
|
+
export interface ResolvedPackkitConfig extends PackkitConfig {
|
|
61
|
+
isTs: boolean;
|
|
62
|
+
isReact: boolean;
|
|
63
|
+
isVue: boolean;
|
|
64
|
+
isSvelte: boolean;
|
|
65
|
+
hasFramework: boolean;
|
|
66
|
+
hasApp: boolean;
|
|
67
|
+
hasLibrary: boolean;
|
|
68
|
+
hasCli: boolean;
|
|
69
|
+
hasService: boolean;
|
|
70
|
+
hasBuild: boolean;
|
|
71
|
+
publishable: boolean;
|
|
72
|
+
preset?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ProjectSummary {
|
|
76
|
+
name: string;
|
|
77
|
+
fileCount: number;
|
|
78
|
+
stack: string[];
|
|
79
|
+
workflows: string[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface GenerateResult {
|
|
83
|
+
config: ResolvedPackkitConfig;
|
|
84
|
+
files: Record<string, string>;
|
|
85
|
+
postCommands: string[];
|
|
86
|
+
summary: ProjectSummary;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function generate(input?: PackkitConfig): GenerateResult;
|
|
90
|
+
export function fromPreset(name: string, overrides?: PackkitConfig): ResolvedPackkitConfig;
|
|
91
|
+
export function normalizeConfig(input?: PackkitConfig, diagnostics?: unknown[]): ResolvedPackkitConfig;
|
|
92
|
+
export function resolvePreset(name: string): string | undefined;
|
|
93
|
+
|
|
94
|
+
export const OPTIONS: Record<string, unknown>;
|
|
95
|
+
export const PRESET_NAMES: string[];
|
|
96
|
+
export const PRESET_ALIASES: Record<string, string>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Public types for the embedded API (create-packkit/embedded).
|
|
2
|
+
|
|
3
|
+
import type { PackkitConfig, ResolvedPackkitConfig, ProjectSummary } from './core.js';
|
|
4
|
+
|
|
5
|
+
export type DiagnosticSeverity = 'info' | 'warning' | 'error';
|
|
6
|
+
|
|
7
|
+
export interface Diagnostic {
|
|
8
|
+
severity: DiagnosticSeverity;
|
|
9
|
+
code: string;
|
|
10
|
+
message: string;
|
|
11
|
+
field?: string;
|
|
12
|
+
source?: string;
|
|
13
|
+
previousValue?: unknown;
|
|
14
|
+
resolvedValue?: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface GeneratedProjectMetadata {
|
|
18
|
+
packkitVersion: string;
|
|
19
|
+
schemaVersion: number;
|
|
20
|
+
preset?: string;
|
|
21
|
+
generatedAt?: string;
|
|
22
|
+
extension?: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type DeploymentType = 'static' | 'node-service' | 'library' | 'cli';
|
|
26
|
+
|
|
27
|
+
export interface DeploymentContract {
|
|
28
|
+
type: DeploymentType;
|
|
29
|
+
buildCommand?: string;
|
|
30
|
+
outputDirectory?: string;
|
|
31
|
+
startCommand?: string;
|
|
32
|
+
port?: number;
|
|
33
|
+
healthCheckPath?: string;
|
|
34
|
+
requiredEnvironmentVariables?: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface GeneratedProject {
|
|
38
|
+
config: ResolvedPackkitConfig;
|
|
39
|
+
files: Record<string, string>;
|
|
40
|
+
summary: ProjectSummary;
|
|
41
|
+
diagnostics: Diagnostic[];
|
|
42
|
+
metadata: GeneratedProjectMetadata;
|
|
43
|
+
deploymentContract: DeploymentContract;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface CreateProjectInput {
|
|
47
|
+
name?: string;
|
|
48
|
+
preset?: string;
|
|
49
|
+
config?: PackkitConfig;
|
|
50
|
+
overrides?: PackkitConfig;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type CollisionPolicy = 'error' | 'skip' | 'overwrite';
|
|
54
|
+
|
|
55
|
+
export interface ProjectExtension {
|
|
56
|
+
files?: Record<string, string>;
|
|
57
|
+
packageJson?: Record<string, unknown>;
|
|
58
|
+
metadata?: Record<string, unknown>;
|
|
59
|
+
collisionPolicy?: CollisionPolicy;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** How a stored extension file relates to generated output: `add` = the host
|
|
63
|
+
* introduced a new path; `replace` = it deliberately overrode a generated one. */
|
|
64
|
+
export interface StoredExtensionFile {
|
|
65
|
+
content: string;
|
|
66
|
+
mode: 'add' | 'replace';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface PackkitProjectDefinition {
|
|
70
|
+
schemaVersion: number;
|
|
71
|
+
packkitVersion: string;
|
|
72
|
+
preset?: string;
|
|
73
|
+
config: PackkitConfig;
|
|
74
|
+
extensions?: {
|
|
75
|
+
files?: Record<string, StoredExtensionFile>;
|
|
76
|
+
packageJson?: Record<string, unknown>;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class PackkitValidationError extends Error {
|
|
81
|
+
readonly code: 'PACKKIT_VALIDATION_FAILED';
|
|
82
|
+
diagnostics: Diagnostic[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const SCHEMA_VERSION: number;
|
|
86
|
+
|
|
87
|
+
export function createProject(input?: CreateProjectInput): GeneratedProject;
|
|
88
|
+
export function extendProject(project: GeneratedProject, extension?: ProjectExtension): GeneratedProject;
|
|
89
|
+
export function exportProjectDefinition(project: GeneratedProject): PackkitProjectDefinition;
|
|
90
|
+
export function createProjectFromDefinition(definition: PackkitProjectDefinition): GeneratedProject;
|
|
91
|
+
export function calculateProjectDigest(project: GeneratedProject): string;
|
|
92
|
+
export function deriveDeploymentContract(config: ResolvedPackkitConfig): DeploymentContract;
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Package root types: the generation core plus the Node embedded API.
|
|
2
|
+
|
|
3
|
+
export * from './core.js';
|
|
4
|
+
export * from './embedded.js';
|
|
5
|
+
export { writeGeneratedProject, PackkitWriteError } from './writer.js';
|
|
6
|
+
export type { WriteGeneratedProjectInput, WriteResult } from './writer.js';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Public types for the writer (create-packkit/writer).
|
|
2
|
+
|
|
3
|
+
import type { GeneratedProject, Diagnostic, CollisionPolicy } from './embedded.js';
|
|
4
|
+
|
|
5
|
+
export interface WriteGeneratedProjectInput {
|
|
6
|
+
project: GeneratedProject;
|
|
7
|
+
destination: string;
|
|
8
|
+
collisionPolicy?: CollisionPolicy;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface WriteResult {
|
|
12
|
+
destination: string;
|
|
13
|
+
writtenFiles: string[];
|
|
14
|
+
skippedFiles: string[];
|
|
15
|
+
diagnostics: Diagnostic[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class PackkitWriteError extends Error {
|
|
19
|
+
code: string;
|
|
20
|
+
path?: string;
|
|
21
|
+
destination?: string;
|
|
22
|
+
cause?: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function writeGeneratedProject(input: WriteGeneratedProjectInput): Promise<WriteResult>;
|