create-shape-app 0.1.2
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 +72 -0
- package/bin/create-shape-app.js +5 -0
- package/dist/cli/args.d.ts +12 -0
- package/dist/cli/args.js +82 -0
- package/dist/cli/errors.d.ts +3 -0
- package/dist/cli/errors.js +6 -0
- package/dist/cli/help.d.ts +1 -0
- package/dist/cli/help.js +18 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +181 -0
- package/dist/scaffold/post-setup.d.ts +16 -0
- package/dist/scaffold/post-setup.js +101 -0
- package/dist/template/materialize.d.ts +6 -0
- package/dist/template/materialize.js +63 -0
- package/dist/template/project.d.ts +2 -0
- package/dist/template/project.js +40 -0
- package/dist/template/release.d.ts +14 -0
- package/dist/template/release.js +127 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shape Network
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# create-shape-app
|
|
2
|
+
|
|
3
|
+
CLI for scaffolding Shape apps from the Builder Kit template pinned to release tags.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun create shape-app my-app --yes
|
|
9
|
+
cd my-app
|
|
10
|
+
bun run type-check
|
|
11
|
+
bun run lint
|
|
12
|
+
bun run contracts:compile
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Equivalent commands:
|
|
16
|
+
- `npm create shape-app@latest my-app -- --yes`
|
|
17
|
+
- `pnpm dlx create-shape-app my-app --yes`
|
|
18
|
+
- `yarn create shape-app my-app --yes`
|
|
19
|
+
|
|
20
|
+
## CLI Usage
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
create-shape-app [project-name] [options]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Options:
|
|
27
|
+
- `-y, --yes`
|
|
28
|
+
- `--pm <bun|npm|pnpm|yarn>`
|
|
29
|
+
- `--skip-install`
|
|
30
|
+
- `--skip-git`
|
|
31
|
+
- `--template-ref <tag>`
|
|
32
|
+
|
|
33
|
+
## Behavior
|
|
34
|
+
- Scaffolds from `shape-network/builder-kit` release tags only (`latest` by default).
|
|
35
|
+
- Rejects non-release refs (for example `main`) and canary tags.
|
|
36
|
+
- Copies template files, excluding VCS/internal maintainer metadata.
|
|
37
|
+
- Applies defaults:
|
|
38
|
+
- Root `package.json` name is set from the project directory name.
|
|
39
|
+
- `.env.example` is copied to `.env` when present.
|
|
40
|
+
- Dependencies are installed unless `--skip-install` is set.
|
|
41
|
+
- Git is initialized unless `--skip-git` is set.
|
|
42
|
+
|
|
43
|
+
## Troubleshooting
|
|
44
|
+
- GitHub API rate limit during template lookup:
|
|
45
|
+
- retry later, set `GITHUB_TOKEN`, or pass `--template-ref <tag>`.
|
|
46
|
+
- Git init or commit failure:
|
|
47
|
+
- scaffold still succeeds; run `git init && git add -A && git commit -m "Initial commit"` manually.
|
|
48
|
+
- Dependency install failure:
|
|
49
|
+
- rerun your package manager (`bun install`, `npm install`, `pnpm install`, or `yarn install`) inside generated app.
|
|
50
|
+
|
|
51
|
+
## Local Development
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
bun install
|
|
55
|
+
bun run lint
|
|
56
|
+
bun run type-check
|
|
57
|
+
bun run test
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Builder Kit README quickstart update snippet is tracked in:
|
|
61
|
+
- `docs/builder-kit-quickstart.md`
|
|
62
|
+
|
|
63
|
+
## Publish
|
|
64
|
+
- Trigger: GitHub release publish event.
|
|
65
|
+
- Guard: workflow checks release tag matches `package.json` version.
|
|
66
|
+
- Requirement: repository secret `NPM_TOKEN` must be configured.
|
|
67
|
+
- Publish target: npm package `create-shape-app`.
|
|
68
|
+
|
|
69
|
+
## Community
|
|
70
|
+
- Contribution guide: `CONTRIBUTING.md`
|
|
71
|
+
- Security policy: `SECURITY.md`
|
|
72
|
+
- Code of conduct: `CODE_OF_CONDUCT.md`
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type PackageManager = 'bun' | 'npm' | 'pnpm' | 'yarn';
|
|
2
|
+
export interface CliOptions {
|
|
3
|
+
help: boolean;
|
|
4
|
+
version: boolean;
|
|
5
|
+
yes: boolean;
|
|
6
|
+
skipInstall: boolean;
|
|
7
|
+
skipGit: boolean;
|
|
8
|
+
projectName?: string;
|
|
9
|
+
packageManager?: PackageManager;
|
|
10
|
+
templateRef?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function parseArgs(argv: string[]): CliOptions;
|
package/dist/cli/args.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { CliUsageError } from './errors.js';
|
|
2
|
+
const VALID_PACKAGE_MANAGERS = new Set(['bun', 'npm', 'pnpm', 'yarn']);
|
|
3
|
+
export function parseArgs(argv) {
|
|
4
|
+
const options = {
|
|
5
|
+
help: false,
|
|
6
|
+
version: false,
|
|
7
|
+
yes: false,
|
|
8
|
+
skipInstall: false,
|
|
9
|
+
skipGit: false,
|
|
10
|
+
};
|
|
11
|
+
let nextValueFor;
|
|
12
|
+
for (const arg of argv) {
|
|
13
|
+
if (nextValueFor) {
|
|
14
|
+
assignFlagValue(options, nextValueFor, arg);
|
|
15
|
+
nextValueFor = undefined;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (arg === '--yes' || arg === '-y') {
|
|
19
|
+
options.yes = true;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (arg === '--skip-install') {
|
|
23
|
+
options.skipInstall = true;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (arg === '--skip-git') {
|
|
27
|
+
options.skipGit = true;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (arg === '--help' || arg === '-h') {
|
|
31
|
+
options.help = true;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (arg === '--version' || arg === '-v') {
|
|
35
|
+
options.version = true;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (arg === '--pm' || arg === '--template-ref') {
|
|
39
|
+
nextValueFor = arg;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (arg.startsWith('--pm=')) {
|
|
43
|
+
assignPackageManager(options, arg.slice('--pm='.length));
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (arg.startsWith('--template-ref=')) {
|
|
47
|
+
options.templateRef = parseRequiredValue('--template-ref', arg.slice('--template-ref='.length));
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (arg.startsWith('-')) {
|
|
51
|
+
throw new CliUsageError(`Unknown option: ${arg}`);
|
|
52
|
+
}
|
|
53
|
+
if (options.projectName) {
|
|
54
|
+
throw new CliUsageError('Only one project name may be provided.');
|
|
55
|
+
}
|
|
56
|
+
options.projectName = arg;
|
|
57
|
+
}
|
|
58
|
+
if (nextValueFor) {
|
|
59
|
+
throw new CliUsageError(`Missing value for ${nextValueFor}`);
|
|
60
|
+
}
|
|
61
|
+
return options;
|
|
62
|
+
}
|
|
63
|
+
function assignFlagValue(options, flag, value) {
|
|
64
|
+
const nextValue = parseRequiredValue(flag, value);
|
|
65
|
+
if (flag === '--pm') {
|
|
66
|
+
assignPackageManager(options, nextValue);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
options.templateRef = nextValue;
|
|
70
|
+
}
|
|
71
|
+
function assignPackageManager(options, value) {
|
|
72
|
+
if (!VALID_PACKAGE_MANAGERS.has(value)) {
|
|
73
|
+
throw new CliUsageError(`Unsupported package manager: ${value}`);
|
|
74
|
+
}
|
|
75
|
+
options.packageManager = value;
|
|
76
|
+
}
|
|
77
|
+
function parseRequiredValue(flag, value) {
|
|
78
|
+
if (!value.trim()) {
|
|
79
|
+
throw new CliUsageError(`Missing value for ${flag}`);
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const HELP_TEXT: string;
|
package/dist/cli/help.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const HELP_TEXT = `
|
|
2
|
+
create-shape-app - Scaffold Shape Builder Kit apps from release tags
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
create-shape-app [project-name] [options]
|
|
6
|
+
|
|
7
|
+
Notes:
|
|
8
|
+
If project-name is omitted in an interactive terminal, you will be prompted.
|
|
9
|
+
|
|
10
|
+
Options:
|
|
11
|
+
-y, --yes Skip confirmation prompts
|
|
12
|
+
--pm <bun|npm|pnpm|yarn> Select package manager
|
|
13
|
+
--skip-install Skip dependency install step
|
|
14
|
+
--skip-git Skip git init + initial commit
|
|
15
|
+
--template-ref <tag> Optional tag override (must still be a release tag)
|
|
16
|
+
-h, --help Show help
|
|
17
|
+
-v, --version Show version
|
|
18
|
+
`.trim();
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type PostScaffoldSetupOptions, type PostScaffoldSetupResult } from './scaffold/post-setup.js';
|
|
2
|
+
import { type MaterializedTemplate } from './template/materialize.js';
|
|
3
|
+
import { type TemplateRelease } from './template/release.js';
|
|
4
|
+
export declare const CLI_VERSION: string;
|
|
5
|
+
interface CliRuntime {
|
|
6
|
+
env: NodeJS.ProcessEnv;
|
|
7
|
+
cwd: string;
|
|
8
|
+
stdinIsTTY: boolean;
|
|
9
|
+
stdoutIsTTY: boolean;
|
|
10
|
+
print: (message: string) => void;
|
|
11
|
+
printError: (message: string) => void;
|
|
12
|
+
prompt: (message: string) => Promise<string>;
|
|
13
|
+
confirm: (message: string) => Promise<boolean>;
|
|
14
|
+
resolveTemplateRelease: (templateRef?: string) => Promise<TemplateRelease>;
|
|
15
|
+
materializeTemplate: (release: TemplateRelease) => Promise<MaterializedTemplate>;
|
|
16
|
+
prepareTargetDirectory: (targetDirectory: string) => Promise<void>;
|
|
17
|
+
copyTemplateToDirectory: (templateRoot: string, targetDirectory: string) => Promise<void>;
|
|
18
|
+
runPostScaffoldSetup: (options: PostScaffoldSetupOptions) => Promise<PostScaffoldSetupResult>;
|
|
19
|
+
}
|
|
20
|
+
export declare function runCLI(argv: string[], runtimeOverrides?: Partial<CliRuntime>): Promise<number>;
|
|
21
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import readline from 'node:readline/promises';
|
|
5
|
+
import { parseArgs } from './cli/args.js';
|
|
6
|
+
import { CliUsageError } from './cli/errors.js';
|
|
7
|
+
import { HELP_TEXT } from './cli/help.js';
|
|
8
|
+
import { runPostScaffoldSetup, } from './scaffold/post-setup.js';
|
|
9
|
+
import { materializeTemplateFromRelease } from './template/materialize.js';
|
|
10
|
+
import { copyTemplateToDirectory, prepareTargetDirectory } from './template/project.js';
|
|
11
|
+
import { fetchTemplateRelease } from './template/release.js';
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const packageJson = require('../package.json');
|
|
14
|
+
export const CLI_VERSION = packageJson.version ?? '0.0.0';
|
|
15
|
+
const DEFAULT_PROJECT_NAME_PROMPT = 'Project name: ';
|
|
16
|
+
const DEFAULT_CONFIRM_PROMPT = 'Continue? (y/N): ';
|
|
17
|
+
export async function runCLI(argv, runtimeOverrides = {}) {
|
|
18
|
+
const runtime = createRuntime(runtimeOverrides);
|
|
19
|
+
try {
|
|
20
|
+
const options = parseArgs(argv);
|
|
21
|
+
if (options.help) {
|
|
22
|
+
runtime.print(HELP_TEXT);
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
if (options.version) {
|
|
26
|
+
runtime.print(CLI_VERSION);
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
const projectName = await resolveProjectName(options.projectName, runtime);
|
|
30
|
+
if (!projectName) {
|
|
31
|
+
runtime.printError('Missing required project name.');
|
|
32
|
+
runtime.printError('');
|
|
33
|
+
runtime.printError(HELP_TEXT);
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
36
|
+
assertValidProjectName(projectName);
|
|
37
|
+
const packageManager = options.packageManager ?? detectPackageManager(runtime.env.npm_config_user_agent);
|
|
38
|
+
const targetDirectory = path.resolve(runtime.cwd, projectName);
|
|
39
|
+
if (!options.yes) {
|
|
40
|
+
if (!isInteractive(runtime)) {
|
|
41
|
+
runtime.printError('Interactive confirmation is unavailable in non-interactive mode.');
|
|
42
|
+
runtime.printError('Re-run with --yes to proceed.');
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
runtime.print('');
|
|
46
|
+
runtime.print('Scaffold request');
|
|
47
|
+
runtime.print(` project: ${projectName}`);
|
|
48
|
+
runtime.print(` target directory: ${targetDirectory}`);
|
|
49
|
+
runtime.print(` package manager: ${packageManager}`);
|
|
50
|
+
runtime.print(` skip install: ${formatBool(options.skipInstall)}`);
|
|
51
|
+
runtime.print(` skip git: ${formatBool(options.skipGit)}`);
|
|
52
|
+
runtime.print(` template ref: ${options.templateRef ?? 'latest release tag'}`);
|
|
53
|
+
runtime.print('');
|
|
54
|
+
const shouldContinue = await runtime.confirm(DEFAULT_CONFIRM_PROMPT);
|
|
55
|
+
if (!shouldContinue) {
|
|
56
|
+
runtime.printError('Aborted.');
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const release = await runtime.resolveTemplateRelease(options.templateRef);
|
|
61
|
+
runtime.print(`Using Builder Kit release ${release.tag}`);
|
|
62
|
+
const materializedTemplate = await runtime.materializeTemplate(release);
|
|
63
|
+
try {
|
|
64
|
+
await runtime.prepareTargetDirectory(targetDirectory);
|
|
65
|
+
await runtime.copyTemplateToDirectory(materializedTemplate.templateRoot, targetDirectory);
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
await materializedTemplate.cleanup();
|
|
69
|
+
}
|
|
70
|
+
const setupResult = await runtime.runPostScaffoldSetup({
|
|
71
|
+
targetDirectory,
|
|
72
|
+
projectName,
|
|
73
|
+
packageManager,
|
|
74
|
+
skipInstall: options.skipInstall,
|
|
75
|
+
skipGit: options.skipGit,
|
|
76
|
+
});
|
|
77
|
+
runtime.print(`Scaffolded ${projectName} from builder-kit@${release.tag}.`);
|
|
78
|
+
runtime.print(`Dependencies: ${options.skipInstall ? 'skipped' : `installed via ${packageManager}`}`);
|
|
79
|
+
if (setupResult.gitStatus === 'initialized') {
|
|
80
|
+
runtime.print('Git setup: initialized with initial commit');
|
|
81
|
+
}
|
|
82
|
+
else if (setupResult.gitStatus === 'skipped') {
|
|
83
|
+
runtime.print('Git setup: skipped');
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
runtime.print('Git setup: skipped due to git initialization failure');
|
|
87
|
+
runtime.printError(`Warning: ${setupResult.gitFailureMessage ?? 'Unable to initialize git repository.'}`);
|
|
88
|
+
}
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
const message = error instanceof Error ? error.message : 'Unexpected error';
|
|
93
|
+
runtime.printError(message);
|
|
94
|
+
if (error instanceof CliUsageError) {
|
|
95
|
+
runtime.printError('');
|
|
96
|
+
runtime.printError(HELP_TEXT);
|
|
97
|
+
}
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function createRuntime(overrides) {
|
|
102
|
+
const env = process.env;
|
|
103
|
+
return {
|
|
104
|
+
env,
|
|
105
|
+
cwd: process.cwd(),
|
|
106
|
+
stdinIsTTY: Boolean(process.stdin.isTTY),
|
|
107
|
+
stdoutIsTTY: Boolean(process.stdout.isTTY),
|
|
108
|
+
print: console.log,
|
|
109
|
+
printError: console.error,
|
|
110
|
+
prompt: defaultPrompt,
|
|
111
|
+
confirm: defaultConfirm,
|
|
112
|
+
resolveTemplateRelease: (templateRef) => fetchTemplateRelease({
|
|
113
|
+
templateRef,
|
|
114
|
+
githubToken: env.GITHUB_TOKEN,
|
|
115
|
+
}),
|
|
116
|
+
materializeTemplate: materializeTemplateFromRelease,
|
|
117
|
+
prepareTargetDirectory,
|
|
118
|
+
copyTemplateToDirectory,
|
|
119
|
+
runPostScaffoldSetup,
|
|
120
|
+
...overrides,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
async function resolveProjectName(projectName, runtime) {
|
|
124
|
+
if (projectName) {
|
|
125
|
+
return projectName.trim();
|
|
126
|
+
}
|
|
127
|
+
if (!isInteractive(runtime)) {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
const answer = await runtime.prompt(DEFAULT_PROJECT_NAME_PROMPT);
|
|
131
|
+
const nextProjectName = answer.trim();
|
|
132
|
+
return nextProjectName || undefined;
|
|
133
|
+
}
|
|
134
|
+
function isInteractive(runtime) {
|
|
135
|
+
return runtime.stdinIsTTY && runtime.stdoutIsTTY;
|
|
136
|
+
}
|
|
137
|
+
async function defaultPrompt(message) {
|
|
138
|
+
const rl = readline.createInterface({
|
|
139
|
+
input: process.stdin,
|
|
140
|
+
output: process.stdout,
|
|
141
|
+
});
|
|
142
|
+
try {
|
|
143
|
+
return await rl.question(message);
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
rl.close();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async function defaultConfirm(message) {
|
|
150
|
+
const answer = await defaultPrompt(message);
|
|
151
|
+
return /^(y|yes)$/i.test(answer.trim());
|
|
152
|
+
}
|
|
153
|
+
function assertValidProjectName(projectName) {
|
|
154
|
+
if (projectName === '.' || projectName === '..') {
|
|
155
|
+
throw new CliUsageError('Invalid project name: "." and ".." are not allowed.');
|
|
156
|
+
}
|
|
157
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(projectName)) {
|
|
158
|
+
throw new CliUsageError('Invalid project name: use only letters, numbers, ".", "-", or "_" and no path separators.');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function detectPackageManager(userAgent) {
|
|
162
|
+
if (typeof userAgent === 'string') {
|
|
163
|
+
if (userAgent.startsWith('pnpm/')) {
|
|
164
|
+
return 'pnpm';
|
|
165
|
+
}
|
|
166
|
+
if (userAgent.startsWith('yarn/')) {
|
|
167
|
+
return 'yarn';
|
|
168
|
+
}
|
|
169
|
+
if (userAgent.startsWith('bun/')) {
|
|
170
|
+
return 'bun';
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return 'npm';
|
|
174
|
+
}
|
|
175
|
+
function formatBool(value) {
|
|
176
|
+
return value ? 'yes' : 'no';
|
|
177
|
+
}
|
|
178
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
179
|
+
const code = await runCLI(process.argv.slice(2));
|
|
180
|
+
process.exit(code);
|
|
181
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PackageManager } from '../cli/args.js';
|
|
2
|
+
export interface PostScaffoldSetupOptions {
|
|
3
|
+
targetDirectory: string;
|
|
4
|
+
projectName: string;
|
|
5
|
+
packageManager: PackageManager;
|
|
6
|
+
skipInstall: boolean;
|
|
7
|
+
skipGit: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface PostScaffoldSetupResult {
|
|
10
|
+
gitStatus: 'initialized' | 'skipped' | 'failed';
|
|
11
|
+
gitFailureMessage?: string;
|
|
12
|
+
}
|
|
13
|
+
type CommandRunner = (command: string, args: string[], cwd: string) => Promise<void>;
|
|
14
|
+
export declare function runPostScaffoldSetup(options: PostScaffoldSetupOptions, runCommand?: CommandRunner): Promise<PostScaffoldSetupResult>;
|
|
15
|
+
export declare function getInstallCommand(packageManager: PackageManager): [string, string[]];
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { copyFile, readFile, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
const INITIAL_COMMIT_MESSAGE = 'Initial commit from create-shape-app';
|
|
5
|
+
export async function runPostScaffoldSetup(options, runCommand = executeCommand) {
|
|
6
|
+
await applyProjectDefaults(options.targetDirectory, options.projectName);
|
|
7
|
+
if (!options.skipInstall) {
|
|
8
|
+
const [command, args] = getInstallCommand(options.packageManager);
|
|
9
|
+
await runCommand(command, args, options.targetDirectory);
|
|
10
|
+
}
|
|
11
|
+
if (options.skipGit) {
|
|
12
|
+
return { gitStatus: 'skipped' };
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
await runCommand('git', ['init'], options.targetDirectory);
|
|
16
|
+
await runCommand('git', ['add', '--all'], options.targetDirectory);
|
|
17
|
+
await runCommand('git', ['commit', '-m', INITIAL_COMMIT_MESSAGE], options.targetDirectory);
|
|
18
|
+
return { gitStatus: 'initialized' };
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
const message = error instanceof Error ? error.message : 'Unknown git error';
|
|
22
|
+
return {
|
|
23
|
+
gitStatus: 'failed',
|
|
24
|
+
gitFailureMessage: message,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function getInstallCommand(packageManager) {
|
|
29
|
+
if (packageManager === 'bun') {
|
|
30
|
+
return ['bun', ['install']];
|
|
31
|
+
}
|
|
32
|
+
if (packageManager === 'pnpm') {
|
|
33
|
+
return ['pnpm', ['install']];
|
|
34
|
+
}
|
|
35
|
+
if (packageManager === 'yarn') {
|
|
36
|
+
return ['yarn', ['install']];
|
|
37
|
+
}
|
|
38
|
+
return ['npm', ['install']];
|
|
39
|
+
}
|
|
40
|
+
async function applyProjectDefaults(targetDirectory, projectName) {
|
|
41
|
+
await applyPackageNameSubstitution(targetDirectory, projectName);
|
|
42
|
+
await applyEnvFileDefaults(targetDirectory);
|
|
43
|
+
}
|
|
44
|
+
async function applyPackageNameSubstitution(targetDirectory, projectName) {
|
|
45
|
+
const packageJsonPath = join(targetDirectory, 'package.json');
|
|
46
|
+
if (!(await pathExists(packageJsonPath))) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const raw = await readFile(packageJsonPath, 'utf8');
|
|
50
|
+
const parsed = JSON.parse(raw);
|
|
51
|
+
parsed.name = toPackageName(projectName);
|
|
52
|
+
await writeFile(packageJsonPath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8');
|
|
53
|
+
}
|
|
54
|
+
async function applyEnvFileDefaults(targetDirectory) {
|
|
55
|
+
const sourcePath = join(targetDirectory, '.env.example');
|
|
56
|
+
const targetPath = join(targetDirectory, '.env');
|
|
57
|
+
if (!(await pathExists(sourcePath)) || (await pathExists(targetPath))) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
await copyFile(sourcePath, targetPath);
|
|
61
|
+
}
|
|
62
|
+
function toPackageName(projectName) {
|
|
63
|
+
const normalized = projectName.trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-');
|
|
64
|
+
const withoutLeading = normalized.replace(/^[._-]+/, '');
|
|
65
|
+
if (!withoutLeading) {
|
|
66
|
+
throw new Error('Unable to derive package name from project name.');
|
|
67
|
+
}
|
|
68
|
+
return withoutLeading;
|
|
69
|
+
}
|
|
70
|
+
async function pathExists(targetPath) {
|
|
71
|
+
try {
|
|
72
|
+
await stat(targetPath);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
const code = error instanceof Error && 'code' in error ? error.code : undefined;
|
|
77
|
+
if (code === 'ENOENT') {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function executeCommand(command, args, cwd) {
|
|
84
|
+
await new Promise((resolve, reject) => {
|
|
85
|
+
const child = spawn(command, args, {
|
|
86
|
+
cwd,
|
|
87
|
+
stdio: 'inherit',
|
|
88
|
+
env: process.env,
|
|
89
|
+
});
|
|
90
|
+
child.once('error', (error) => {
|
|
91
|
+
reject(new Error(`Failed to start command "${command}": ${error.message}`));
|
|
92
|
+
});
|
|
93
|
+
child.once('close', (code) => {
|
|
94
|
+
if (code === 0) {
|
|
95
|
+
resolve();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
reject(new Error(`Command "${[command, ...args].join(' ')}" failed with exit code ${String(code)}`));
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { TemplateRelease } from './release.js';
|
|
2
|
+
export interface MaterializedTemplate {
|
|
3
|
+
templateRoot: string;
|
|
4
|
+
cleanup: () => Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
export declare function materializeTemplateFromRelease(release: TemplateRelease, fetchImpl?: typeof fetch): Promise<MaterializedTemplate>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
export async function materializeTemplateFromRelease(release, fetchImpl = fetch) {
|
|
6
|
+
const tempRoot = await mkdtemp(join(tmpdir(), 'create-shape-app-'));
|
|
7
|
+
const tarballPath = join(tempRoot, 'template.tar.gz');
|
|
8
|
+
const extractedPath = join(tempRoot, 'extracted');
|
|
9
|
+
try {
|
|
10
|
+
await downloadTarball(release.tarballUrl, tarballPath, fetchImpl);
|
|
11
|
+
await mkdir(extractedPath, { recursive: true });
|
|
12
|
+
await extractTarball(tarballPath, extractedPath);
|
|
13
|
+
const templateRoot = await findArchiveRootDirectory(extractedPath);
|
|
14
|
+
return {
|
|
15
|
+
templateRoot,
|
|
16
|
+
cleanup: async () => {
|
|
17
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async function downloadTarball(url, outputPath, fetchImpl) {
|
|
27
|
+
const response = await fetchImpl(url, {
|
|
28
|
+
headers: {
|
|
29
|
+
Accept: 'application/octet-stream',
|
|
30
|
+
'User-Agent': 'create-shape-app',
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
throw new Error(`Failed to download template tarball: HTTP ${response.status}`);
|
|
35
|
+
}
|
|
36
|
+
const data = Buffer.from(await response.arrayBuffer());
|
|
37
|
+
await writeFile(outputPath, data);
|
|
38
|
+
}
|
|
39
|
+
async function extractTarball(tarballPath, outputDir) {
|
|
40
|
+
await new Promise((resolve, reject) => {
|
|
41
|
+
const child = spawn('tar', ['-xzf', tarballPath, '-C', outputDir], {
|
|
42
|
+
stdio: 'ignore',
|
|
43
|
+
});
|
|
44
|
+
child.once('error', (error) => {
|
|
45
|
+
reject(new Error(`Failed to run tar: ${error.message}`));
|
|
46
|
+
});
|
|
47
|
+
child.once('close', (code) => {
|
|
48
|
+
if (code === 0) {
|
|
49
|
+
resolve();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
reject(new Error(`tar extraction failed with exit code ${String(code)}`));
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async function findArchiveRootDirectory(extractedPath) {
|
|
57
|
+
const entries = await readdir(extractedPath, { withFileTypes: true });
|
|
58
|
+
const rootDirectory = entries.find((entry) => entry.isDirectory());
|
|
59
|
+
if (!rootDirectory) {
|
|
60
|
+
throw new Error('Template archive did not contain a root directory.');
|
|
61
|
+
}
|
|
62
|
+
return join(extractedPath, basename(rootDirectory.name));
|
|
63
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { cp, mkdir, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
const EXCLUDED_TEMPLATE_ENTRIES = new Set(['.git', '.claude', 'AGENTS.md', 'CLAUDE.md', '.DS_Store']);
|
|
4
|
+
export async function prepareTargetDirectory(targetDirectory) {
|
|
5
|
+
const directoryExists = await pathExists(targetDirectory);
|
|
6
|
+
if (!directoryExists) {
|
|
7
|
+
await mkdir(targetDirectory, { recursive: true });
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const metadata = await stat(targetDirectory);
|
|
11
|
+
if (!metadata.isDirectory()) {
|
|
12
|
+
throw new Error(`Target path exists and is not a directory: ${targetDirectory}`);
|
|
13
|
+
}
|
|
14
|
+
const entries = await readdir(targetDirectory);
|
|
15
|
+
if (entries.length > 0) {
|
|
16
|
+
throw new Error(`Target directory is not empty: ${targetDirectory}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function copyTemplateToDirectory(templateRoot, targetDirectory) {
|
|
20
|
+
await cp(templateRoot, targetDirectory, {
|
|
21
|
+
recursive: true,
|
|
22
|
+
force: false,
|
|
23
|
+
filter(sourcePath) {
|
|
24
|
+
return !EXCLUDED_TEMPLATE_ENTRIES.has(basename(sourcePath));
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
async function pathExists(targetPath) {
|
|
29
|
+
try {
|
|
30
|
+
await stat(targetPath);
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
const code = error instanceof Error && 'code' in error ? error.code : undefined;
|
|
35
|
+
if (code === 'ENOENT') {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface TemplateRelease {
|
|
2
|
+
tag: string;
|
|
3
|
+
tarballUrl: string;
|
|
4
|
+
}
|
|
5
|
+
export interface FetchTemplateReleaseOptions {
|
|
6
|
+
owner?: string;
|
|
7
|
+
repo?: string;
|
|
8
|
+
templateRef?: string;
|
|
9
|
+
githubToken?: string;
|
|
10
|
+
fetchImpl?: typeof fetch;
|
|
11
|
+
sleepImpl?: (milliseconds: number) => Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export declare function assertTagIsSupported(tag: string): void;
|
|
14
|
+
export declare function fetchTemplateRelease(options?: FetchTemplateReleaseOptions): Promise<TemplateRelease>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
const DEFAULT_OWNER = 'shape-network';
|
|
2
|
+
const DEFAULT_REPO = 'builder-kit';
|
|
3
|
+
const RELEASE_TAG_PATTERN = /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
4
|
+
export function assertTagIsSupported(tag) {
|
|
5
|
+
if (!RELEASE_TAG_PATTERN.test(tag)) {
|
|
6
|
+
throw new Error(`Invalid release tag "${tag}". Only semantic-version tags like "v1.2.3" are supported.`);
|
|
7
|
+
}
|
|
8
|
+
if (tag.toLowerCase().includes('canary')) {
|
|
9
|
+
throw new Error(`Unsupported release tag "${tag}". Canary tags are not allowed.`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export async function fetchTemplateRelease(options = {}) {
|
|
13
|
+
const owner = options.owner ?? DEFAULT_OWNER;
|
|
14
|
+
const repo = options.repo ?? DEFAULT_REPO;
|
|
15
|
+
const templateRef = options.templateRef;
|
|
16
|
+
const githubToken = options.githubToken;
|
|
17
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
18
|
+
const sleepImpl = options.sleepImpl ?? sleep;
|
|
19
|
+
const maxAttempts = 3;
|
|
20
|
+
if (templateRef) {
|
|
21
|
+
assertTagIsSupported(templateRef);
|
|
22
|
+
}
|
|
23
|
+
const endpoint = templateRef
|
|
24
|
+
? `https://api.github.com/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(templateRef)}`
|
|
25
|
+
: `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
|
|
26
|
+
let response;
|
|
27
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
28
|
+
response = await fetchImpl(endpoint, {
|
|
29
|
+
headers: buildHeaders(githubToken),
|
|
30
|
+
});
|
|
31
|
+
if (response.ok) {
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
if (!isRetryableStatus(response.status) || attempt === maxAttempts) {
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
await sleepImpl(getRetryDelayMilliseconds(response, attempt));
|
|
38
|
+
}
|
|
39
|
+
if (!response) {
|
|
40
|
+
throw new Error('Failed to resolve template release: no response received.');
|
|
41
|
+
}
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
throw new Error(await buildReleaseLookupError(response, templateRef));
|
|
44
|
+
}
|
|
45
|
+
const payload = (await response.json());
|
|
46
|
+
const tagName = payload.tag_name;
|
|
47
|
+
const tarballUrl = payload.tarball_url;
|
|
48
|
+
if (typeof tagName !== 'string' || typeof tarballUrl !== 'string') {
|
|
49
|
+
throw new Error('Invalid release payload from GitHub API.');
|
|
50
|
+
}
|
|
51
|
+
assertTagIsSupported(tagName);
|
|
52
|
+
return {
|
|
53
|
+
tag: tagName,
|
|
54
|
+
tarballUrl,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function buildHeaders(githubToken) {
|
|
58
|
+
const headers = {
|
|
59
|
+
Accept: 'application/vnd.github+json',
|
|
60
|
+
'User-Agent': 'create-shape-app',
|
|
61
|
+
};
|
|
62
|
+
if (githubToken) {
|
|
63
|
+
headers.Authorization = `Bearer ${githubToken}`;
|
|
64
|
+
}
|
|
65
|
+
return headers;
|
|
66
|
+
}
|
|
67
|
+
async function buildReleaseLookupError(response, templateRef) {
|
|
68
|
+
const refLabel = templateRef ? `release tag "${templateRef}"` : 'latest release';
|
|
69
|
+
const apiMessage = await readApiMessage(response);
|
|
70
|
+
const rateLimited = response.status === 429 ||
|
|
71
|
+
(response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0');
|
|
72
|
+
if (rateLimited) {
|
|
73
|
+
const resetHint = getRateLimitResetHint(response.headers.get('x-ratelimit-reset'));
|
|
74
|
+
return [
|
|
75
|
+
`Failed to resolve ${refLabel}: GitHub API rate limit reached (HTTP ${response.status}).`,
|
|
76
|
+
resetHint,
|
|
77
|
+
'Retry later or set GITHUB_TOKEN.',
|
|
78
|
+
]
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
.join(' ');
|
|
81
|
+
}
|
|
82
|
+
if (apiMessage) {
|
|
83
|
+
return `Failed to resolve ${refLabel}: ${apiMessage} (HTTP ${response.status}).`;
|
|
84
|
+
}
|
|
85
|
+
return `Failed to resolve ${refLabel}: HTTP ${response.status}.`;
|
|
86
|
+
}
|
|
87
|
+
async function readApiMessage(response) {
|
|
88
|
+
try {
|
|
89
|
+
const payload = (await response.clone().json());
|
|
90
|
+
if (typeof payload.message === 'string' && payload.message.trim()) {
|
|
91
|
+
return payload.message.trim();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Ignore parse failures and fall back to status-only messaging.
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
function getRateLimitResetHint(resetEpochSeconds) {
|
|
100
|
+
if (!resetEpochSeconds) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
const reset = Number(resetEpochSeconds);
|
|
104
|
+
if (!Number.isFinite(reset)) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
const resetDate = new Date(reset * 1000);
|
|
108
|
+
return `GitHub API reset time: ${resetDate.toISOString()}.`;
|
|
109
|
+
}
|
|
110
|
+
function isRetryableStatus(status) {
|
|
111
|
+
return status === 429 || status >= 500;
|
|
112
|
+
}
|
|
113
|
+
function getRetryDelayMilliseconds(response, attempt) {
|
|
114
|
+
const retryAfterHeader = response.headers.get('retry-after');
|
|
115
|
+
if (retryAfterHeader) {
|
|
116
|
+
const asSeconds = Number(retryAfterHeader);
|
|
117
|
+
if (!Number.isNaN(asSeconds) && asSeconds >= 0) {
|
|
118
|
+
return Math.floor(asSeconds * 1000);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return Math.min(250 * attempt, 1500);
|
|
122
|
+
}
|
|
123
|
+
async function sleep(milliseconds) {
|
|
124
|
+
await new Promise((resolve) => {
|
|
125
|
+
setTimeout(resolve, milliseconds);
|
|
126
|
+
});
|
|
127
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-shape-app",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Scaffold Shape Builder Kit projects from pinned release tags.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/shape-network/create-shape-app.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/shape-network/create-shape-app/issues"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/shape-network/create-shape-app#readme",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"cli",
|
|
16
|
+
"create",
|
|
17
|
+
"scaffold",
|
|
18
|
+
"shape",
|
|
19
|
+
"builder-kit"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"bin": "./bin/create-shape-app.js",
|
|
26
|
+
"files": [
|
|
27
|
+
"bin",
|
|
28
|
+
"dist",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20.18.0"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc -p tsconfig.build.json",
|
|
37
|
+
"type-check": "tsc -p tsconfig.json --noEmit",
|
|
38
|
+
"lint": "eslint . --max-warnings=0",
|
|
39
|
+
"lint:fix": "eslint . --fix",
|
|
40
|
+
"test": "bun run build && node --test ./test/*.test.mjs",
|
|
41
|
+
"dev": "bun run build && node ./bin/create-shape-app.js --help",
|
|
42
|
+
"prepack": "bun run lint && bun run type-check && bun run test"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@eslint/js": "^9.39.1",
|
|
46
|
+
"@types/node": "^25.2.2",
|
|
47
|
+
"@typescript-eslint/parser": "^8.46.3",
|
|
48
|
+
"eslint": "^9.39.1",
|
|
49
|
+
"globals": "^16.4.0",
|
|
50
|
+
"typescript": "^5.9.3"
|
|
51
|
+
}
|
|
52
|
+
}
|