@jakobm/peacekeeper 0.17.0 → 0.18.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 CHANGED
@@ -6,6 +6,44 @@ The public npm package is `@jakobm/peacekeeper`; its CLI command is `peacekeeper
6
6
  Install it with `npm install --save-exact @jakobm/peacekeeper@<version>`, selecting
7
7
  a published version. Replace the example dependency pin below with that version.
8
8
 
9
+ ## Create a compliant TypeScript project
10
+
11
+ `create-project` creates a minimal ESM TypeScript library in an existing, completely
12
+ empty GitHub repository. The command version owns the generated project contract,
13
+ all tool pins, and the exact `@jakobm/peacekeeper` development dependency. This
14
+ command additionally requires Volta so generation and verification use those pins.
15
+
16
+ ```sh
17
+ peacekeeper create-project \
18
+ --repository https://github.com/OWNER/REPOSITORY.git \
19
+ --directory /absolute/path/to/REPOSITORY \
20
+ --package-name @scope/REPOSITORY \
21
+ --author-name "Example Author" \
22
+ --author-email author@example.com \
23
+ --publish-to-npm true
24
+ ```
25
+
26
+ All arguments are mandatory. Only `https://github.com/OWNER/REPOSITORY[.git]`
27
+ URLs are accepted. The package basename must equal the repository name. Published
28
+ packages must be scoped; private packages must be unscoped and use
29
+ `--publish-to-npm false`.
30
+
31
+ Before creating the destination, the command verifies that it does not exist and
32
+ that the remote has no Git refs. It then installs dependencies, builds, typechecks,
33
+ runs the complete Peacekeeper test and project checks, and (for published packages)
34
+ tests the packed package from a temporary consumer. Only after every local check
35
+ passes does it create one initial commit on `main` and push without force.
36
+
37
+ An argument or preflight failure creates nothing. A later failure leaves the local
38
+ directory for inspection but does not commit or push. A push failure leaves the
39
+ verified local commit. Inspect or remove the directory before retrying.
40
+
41
+ The generated executable `setup-github-config.sh` is deliberately not run by the
42
+ generator. Run it afterwards and complete the manual checklist at its top. It
43
+ configures applicable GitHub policy through `gh` and prompts for the separate
44
+ `RELEASE_POLICY_TOKEN`. Published projects also document the manual npm trusted
45
+ publishing setup; the generator never registers a publisher or publishes a package.
46
+
9
47
  When moving an existing project to Peacekeeper, update its dependency and lockfile,
10
48
  package scripts, and configuration filename together. Replace previous lifecycle
11
49
  hook commands with the definitions below before running `peacekeeper install-hooks`.
@@ -5,6 +5,7 @@ const path = require('node:path');
5
5
 
6
6
  const COMMANDS = new Map([
7
7
  ['update-config', 'Create or complete project configuration using bundled defaults.'],
8
+ ['create-project', 'Create and verify a compliant TypeScript library in an empty GitHub repository.'],
8
9
  ['check-project', 'Validate local toolchain, hooks, CI and publication conventions.'],
9
10
  ['install-hooks', 'Install or update project Codex hooks while preserving other hooks.'],
10
11
  ['verify-release', 'Verify release versions, required CI and strict branch protection.'],
@@ -47,7 +48,7 @@ function main() {
47
48
  const informational = (args.length === 1 && ['--help', '-h'].includes(args[0]))
48
49
  || (command === 'check-project' && args.length === 1 && args[0] === '--explain');
49
50
  const checkpointHook = ['checkpoint-codex-session-start', 'checkpoint-codex-stop'].includes(command);
50
- if (!['update-config', 'assess-release'].includes(command) && !checkpointHook && !informational) require('../scripts/project-config').projectConfig();
51
+ if (!['update-config', 'assess-release', 'create-project'].includes(command) && !checkpointHook && !informational) require('../scripts/project-config').projectConfig();
51
52
  const script = path.resolve(__dirname, '..', 'scripts', `${command}.js`);
52
53
  const result = spawnSync(process.execPath, [script, ...args], {
53
54
  cwd: process.cwd(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jakobm/peacekeeper",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Shared repository test, Git worktree, and Codex hook commands",
5
5
  "license": "MIT",
6
6
  "author": "Jakob Mattsson",
@@ -0,0 +1,168 @@
1
+ 'use strict';
2
+
3
+ const { spawnSync } = require('node:child_process');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const { planHooks } = require('./install-hooks');
7
+
8
+ const TOOLCHAIN = Object.freeze({
9
+ node: '22.14.0',
10
+ npm: '11.21.0',
11
+ typescript: '5.9.3',
12
+ nodeTypes: '24.10.0',
13
+ });
14
+
15
+ class UsageError extends Error {}
16
+
17
+ function usage() {
18
+ return 'Usage: peacekeeper create-project --repository <https://github.com/owner/repo.git> --directory <path> --package-name <name> --author-name <name> --author-email <email> --publish-to-npm <true|false>\n';
19
+ }
20
+
21
+ function repositoryIdentity(repository) {
22
+ let url;
23
+ try { url = new URL(repository); } catch { throw new UsageError('--repository must be a valid GitHub HTTPS URL'); }
24
+ if (url.protocol !== 'https:' || url.hostname !== 'github.com' || url.username || url.password || url.port || url.search || url.hash) {
25
+ throw new UsageError('--repository must use https://github.com/owner/repo.git');
26
+ }
27
+ const match = /^\/([^/]+)\/([^/]+?)(?:\.git)?$/u.exec(url.pathname);
28
+ if (!match || !match[1] || !match[2] || match[1] === '.' || match[2] === '.') {
29
+ throw new UsageError('--repository must use https://github.com/owner/repo.git');
30
+ }
31
+ return { owner: match[1], repositoryName: match[2] };
32
+ }
33
+
34
+ function parseArguments(args) {
35
+ const names = ['repository', 'directory', 'package-name', 'author-name', 'author-email', 'publish-to-npm'];
36
+ const values = {};
37
+ for (let index = 0; index < args.length; index += 2) {
38
+ const flag = args[index];
39
+ const name = flag?.startsWith('--') ? flag.slice(2) : '';
40
+ if (!names.includes(name)) throw new UsageError(`Unknown argument: ${flag || '(missing)'}`);
41
+ if (Object.hasOwn(values, name)) throw new UsageError(`Duplicate argument: --${name}`);
42
+ if (index + 1 >= args.length || args[index + 1].startsWith('--')) throw new UsageError(`Missing value for --${name}`);
43
+ values[name] = args[index + 1];
44
+ }
45
+ const missing = names.filter(name => !Object.hasOwn(values, name));
46
+ if (missing.length) throw new UsageError(`Missing required argument${missing.length === 1 ? '' : 's'}: ${missing.map(name => `--${name}`).join(', ')}`);
47
+ const identity = repositoryIdentity(values.repository);
48
+ if (!values.directory.trim()) throw new UsageError('--directory must be nonempty');
49
+ if (!values['author-name'].trim()) throw new UsageError('--author-name must be nonempty');
50
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(values['author-email'])) throw new UsageError('--author-email must be a valid email address');
51
+ if (!['true', 'false'].includes(values['publish-to-npm'])) throw new UsageError('--publish-to-npm must be true or false');
52
+ const packageName = values['package-name'];
53
+ if (packageName.length > 214 || !/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u.test(packageName)) {
54
+ throw new UsageError('--package-name must be a valid lowercase npm package name');
55
+ }
56
+ const published = values['publish-to-npm'] === 'true';
57
+ const scoped = packageName.startsWith('@');
58
+ if (published !== scoped) throw new UsageError(`--package-name must be ${published ? 'scoped' : 'unscoped'} when --publish-to-npm is ${published}`);
59
+ if (packageName.split('/').at(-1) !== identity.repositoryName) throw new UsageError('The package basename must exactly match the repository name');
60
+ return { repository: values.repository, directory: values.directory, packageName,
61
+ authorName: values['author-name'], authorEmail: values['author-email'], published, ...identity };
62
+ }
63
+
64
+ function commandRunner(command, args, options = {}) {
65
+ const result = spawnSync(command, args, { encoding: 'utf8', stdio: options.capture ? 'pipe' : 'inherit', ...options });
66
+ if (result.error) throw result.error;
67
+ if (result.status !== 0) {
68
+ const detail = options.capture ? (result.stderr || result.stdout || '').trim() : '';
69
+ throw new Error(`${command} ${args.join(' ')} failed${detail ? `: ${detail}` : ` with exit ${result.status}`}`);
70
+ }
71
+ return result.stdout || '';
72
+ }
73
+
74
+ function json(value) { return `${JSON.stringify(value, null, 2)}\n`; }
75
+ function write(root, name, contents, mode = 0o644) {
76
+ const target = path.join(root, name);
77
+ fs.mkdirSync(path.dirname(target), { recursive: true });
78
+ fs.writeFileSync(target, contents, { mode });
79
+ }
80
+
81
+ function workflow(published) {
82
+ return `name: Test\n\non:\n pull_request:\n branches: [main]\n\npermissions:\n contents: read\n\njobs:\n test:\n runs-on: ubuntu-24.04\n steps:\n - uses: actions/checkout@v7\n - uses: volta-cli/action@v5\n - run: npm ci\n - name: Verify strict release policy\n env:\n GH_TOKEN: \${{ secrets.RELEASE_POLICY_TOKEN }}\n run: peacekeeper verify-release-protection\n - run: npm test\n${published ? `\n package:\n name: Test package\n runs-on: ubuntu-24.04\n steps:\n - uses: actions/checkout@v7\n - uses: volta-cli/action@v5\n - run: npm ci\n - run: npm run test:package\n` : ''}`;
83
+ }
84
+
85
+ function publishWorkflow() {
86
+ return `name: Publish\n\non:\n push:\n tags: ['v*']\n\npermissions:\n contents: read\n\njobs:\n publish:\n name: Publish to npm\n runs-on: ubuntu-24.04\n permissions:\n contents: read\n actions: read\n pull-requests: read\n id-token: write\n steps:\n - uses: actions/checkout@v7\n - uses: volta-cli/action@v5\n with:\n registry-url: https://registry.npmjs.org\n - run: npm ci\n - name: Verify release\n env:\n GH_TOKEN: \${{ secrets.RELEASE_POLICY_TOKEN }}\n RELEASE_TAG: \${{ github.ref_name }}\n run: peacekeeper verify-release\n - run: npm publish\n`;
87
+ }
88
+
89
+ function githubSetup({ owner, repositoryName, published }) {
90
+ const npm = published ? `# - Configure npm trusted publishing for GitHub repository ${owner}/${repositoryName} and workflow publish.yml.\n# - Confirm the npm package scope and access before the first release.\n` : '';
91
+ return `#!/usr/bin/env bash\n# Manual checklist:\n# - Run this script yourself after the initial project has been pushed.\n# - Create a fine-grained RELEASE_POLICY_TOKEN with the permissions documented by Peacekeeper.\n${npm}set -euo pipefail\n\nrepo=${owner}/${repositoryName}\ngh auth status\ngh repo view "$repo" >/dev/null\ngh api --method PATCH "repos/$repo" -f delete_branch_on_merge=true >/dev/null\ngh api --method PUT "repos/$repo/branches/main/protection" \\\n -F required_status_checks.strict=true \\\n -f 'required_status_checks.contexts[]=test' \\\n -F enforce_admins=true \\\n -F required_pull_request_reviews=null \\\n -F restrictions=null >/dev/null\nprintf 'Paste RELEASE_POLICY_TOKEN for %s when prompted.\\n' "$repo"\ngh secret set RELEASE_POLICY_TOKEN --repo "$repo"\nprintf 'GitHub configuration completed for %s.\\n' "$repo"\n`;
92
+ }
93
+
94
+ function packageTest() {
95
+ return `'use strict';\nconst assert = require('node:assert/strict');\nconst { execFileSync } = require('node:child_process');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\nconst root = path.resolve(__dirname, '..');\nconst output = JSON.parse(execFileSync('npm', ['pack', '--json'], { cwd: root, encoding: 'utf8' }));\nconst tarball = path.join(root, output[0].filename);\nconst consumer = fs.mkdtempSync(path.join(os.tmpdir(), 'package-consumer-'));\ntry {\n fs.writeFileSync(path.join(consumer, 'package.json'), '{"type":"module"}\\n');\n execFileSync('npm', ['install', '--ignore-scripts', tarball], { cwd: consumer, stdio: 'inherit' });\n const value = execFileSync(process.execPath, ['--input-type=module', '--eval', "import { hello } from '\${PACKAGE_NAME}'; process.stdout.write(hello())"], { cwd: consumer, encoding: 'utf8' });\n assert.equal(value, 'hello world');\n} finally {\n fs.rmSync(consumer, { recursive: true, force: true });\n fs.rmSync(tarball, { force: true });\n}\n`;
96
+ }
97
+
98
+ function generate(root, options) {
99
+ const version = require('../package.json').version;
100
+ const manifest = {
101
+ name: options.packageName, version: '0.1.0', description: `${options.repositoryName} TypeScript library`,
102
+ type: 'module', ...(options.published ? {} : { private: true }),
103
+ author: { name: options.authorName, email: options.authorEmail },
104
+ repository: { type: 'git', url: `git+${options.repository}` }, license: 'MIT',
105
+ engines: { node: '>=20' }, volta: { node: TOOLCHAIN.node, npm: TOOLCHAIN.npm },
106
+ main: './dist/index.js', types: './dist/index.d.ts', exports: { '.': { types: './dist/index.d.ts', import: './dist/index.js' } },
107
+ scripts: { build: 'tsc -p tsconfig.json', typecheck: 'tsc -p tsconfig.json --noEmit', test: 'peacekeeper run-tests', 'test:unit': 'node --test test/*.test.js',
108
+ ...(options.published ? { 'test:package': 'node scripts/test-package.cjs', release: 'peacekeeper release' } : {}) },
109
+ devDependencies: { '@jakobm/peacekeeper': version, '@types/node': TOOLCHAIN.nodeTypes, typescript: TOOLCHAIN.typescript },
110
+ ...(options.published ? { files: ['dist/'], publishConfig: { access: 'public', registry: 'https://registry.npmjs.org/' } } : {}),
111
+ };
112
+ write(root, 'package.json', json(manifest));
113
+ write(root, 'tsconfig.json', json({ compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', rootDir: 'src', outDir: 'dist', declaration: true, strict: true, noUncheckedIndexedAccess: true }, include: ['src/**/*.ts'] }));
114
+ write(root, 'src/index.ts', "export function hello(): string {\n return 'hello world';\n}\n");
115
+ write(root, 'test/index.test.js', `import assert from 'node:assert/strict';\nimport test from 'node:test';\nimport { hello } from '../dist/index.js';\ntest('public hello API', () => assert.equal(hello(), 'hello world'));\n`);
116
+ if (options.published) write(root, 'scripts/test-package.cjs', packageTest().replace('${PACKAGE_NAME}', options.packageName));
117
+ const defaults = structuredClone(require('./project-config-defaults.json'));
118
+ defaults.publishesToNpm = options.published;
119
+ defaults.tests.commands = ['npm run typecheck', 'npm run build', 'npm run test:unit', ...(options.published ? ['npm run test:package'] : [])];
120
+ defaults.tests.npmTestUsesRunner = true;
121
+ write(root, 'peacekeeper.config.json', json(defaults));
122
+ write(root, '.codex/hooks.json', json(planHooks({}).document));
123
+ const prefix = fs.readFileSync(path.join(__dirname, 'codex-setup-prefix.sh'), 'utf8');
124
+ write(root, '.codex/environments/environment.toml', `version = 1\nname = ${JSON.stringify(options.repositoryName)}\n\n[setup]\nscript = '''\n${prefix}'''\n`);
125
+ write(root, '.github/workflows/test.yml', workflow(options.published));
126
+ if (options.published) write(root, '.github/workflows/publish.yml', publishWorkflow());
127
+ write(root, '.gitignore', 'node_modules/\n*.tgz\ntmp/\n');
128
+ write(root, 'README.md', `# ${options.repositoryName}\n\nA TypeScript library generated by Peacekeeper.\n\n## API\n\n\`hello()\` returns \`"hello world"\`.\n`);
129
+ write(root, 'LICENSE', `MIT License\n\nCopyright (c) ${options.authorName}\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n`);
130
+ write(root, 'setup-github-config.sh', githubSetup(options), 0o755);
131
+ }
132
+
133
+ function createProject(options, dependencies = {}) {
134
+ const run = dependencies.run || commandRunner;
135
+ const voltaHome = process.env.VOLTA_HOME;
136
+ const volta = voltaHome && fs.existsSync(path.join(voltaHome, 'bin', 'volta'))
137
+ ? path.join(voltaHome, 'bin', 'volta') : 'volta';
138
+ const npm = args => run(volta, ['run', '--node', TOOLCHAIN.node, '--npm', TOOLCHAIN.npm, 'npm', ...args], { cwd: normalized });
139
+ const target = path.resolve(options.directory);
140
+ if (fs.existsSync(target)) throw new Error(`Destination already exists: ${target}`);
141
+ const parent = path.dirname(target);
142
+ let realParent;
143
+ try { realParent = fs.realpathSync(parent); } catch { throw new Error(`Destination parent does not exist: ${parent}`); }
144
+ const normalized = path.join(realParent, path.basename(target));
145
+ if (fs.existsSync(normalized)) throw new Error(`Destination already exists after path normalization: ${normalized}`);
146
+ const refs = run('git', ['ls-remote', options.repository], { capture: true }).trim();
147
+ if (refs) throw new Error('The repository is not empty; refusing to create a project');
148
+ fs.mkdirSync(normalized);
149
+ try {
150
+ run('git', ['init', '--initial-branch=main'], { cwd: normalized });
151
+ run('git', ['remote', 'add', 'origin', options.repository], { cwd: normalized });
152
+ generate(normalized, options);
153
+ npm(['install', '--ignore-scripts']);
154
+ npm(['run', 'build']);
155
+ npm(['run', 'typecheck']);
156
+ npm(['test']);
157
+ run('bash', ['-n', 'setup-github-config.sh'], { cwd: normalized });
158
+ if (run('git', ['ls-remote', options.repository], { capture: true }).trim()) throw new Error('The repository gained refs while the project was being created');
159
+ run('git', ['add', '--all'], { cwd: normalized });
160
+ run('git', ['-c', `user.name=${options.authorName}`, '-c', `user.email=${options.authorEmail}`, 'commit', '-m', 'Initial project'], { cwd: normalized });
161
+ run('git', ['push', '--set-upstream', 'origin', 'main:main'], { cwd: normalized });
162
+ } catch (error) {
163
+ throw new Error(`${error.message}. Inspect or remove ${normalized} before retrying.`);
164
+ }
165
+ return { directory: normalized, packageName: options.packageName };
166
+ }
167
+
168
+ module.exports = { TOOLCHAIN, UsageError, createProject, generate, parseArguments, repositoryIdentity, usage };
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { createProject, parseArguments, usage, UsageError } = require('./create-project-core');
5
+
6
+ async function main() {
7
+ const args = process.argv.slice(2);
8
+ if (args.length === 1 && ['--help', '-h'].includes(args[0])) {
9
+ process.stdout.write(usage());
10
+ return;
11
+ }
12
+ const options = parseArguments(args);
13
+ const result = createProject(options);
14
+ process.stdout.write(`Created ${result.packageName} at ${result.directory}\n`);
15
+ process.stdout.write(`Next: run ${result.directory}/setup-github-config.sh and complete its manual checklist.\n`);
16
+ }
17
+
18
+ main().catch(error => {
19
+ if (error instanceof UsageError) {
20
+ process.stderr.write(`${error.message}\n\n${usage()}`);
21
+ process.exitCode = 2;
22
+ } else {
23
+ process.stderr.write(`Error: ${error.message}\n`);
24
+ process.exitCode = 1;
25
+ }
26
+ });
@@ -7,9 +7,11 @@ const { repositoryRoot } = require('./project-config');
7
7
 
8
8
  const ROOT = repositoryRoot;
9
9
  const RECEIPT_DIRECTORY = 'peacekeeper-test-receipts';
10
+ const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
10
11
 
11
12
  function git(root, args) {
12
- return execFileSync('git', args, { cwd: root, maxBuffer: 10 * 1024 * 1024 });
13
+ return execFileSync('git', args, { cwd: root, maxBuffer: 10 * 1024 * 1024,
14
+ stdio: ['ignore', 'pipe', 'pipe'] });
13
15
  }
14
16
 
15
17
  function pathsFromOutput(output) {
@@ -64,7 +66,15 @@ function environmentFingerprint() {
64
66
  });
65
67
  }
66
68
 
67
- function snapshot(root = ROOT(), baseCommit = git(root, ['rev-parse', 'HEAD']).toString('utf8').trim()) {
69
+ function currentBase(root) {
70
+ try { return git(root, ['rev-parse', '--verify', 'HEAD']).toString('utf8').trim(); }
71
+ catch (error) {
72
+ if (error.status === 128) return EMPTY_TREE;
73
+ throw error;
74
+ }
75
+ }
76
+
77
+ function snapshot(root = ROOT(), baseCommit = currentBase(root)) {
68
78
  return {
69
79
  baseCommit,
70
80
  environment: environmentFingerprint(),