@ankhorage/devtools 1.2.1 → 1.3.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.
@@ -1,10 +1,36 @@
1
+ /***
2
+ * Create the shared strict ESLint flat configuration used by consuming repositories.
3
+ *
4
+ * The default `profile: 'auto'` reads the nearest `package.json` from `tsconfigRootDir` and uses
5
+ * `@ankhorage/utility/project` to select overlapping project traits. React Native and Expo select
6
+ * the `react-native` profile, React and Next.js select `react`, and other projects use `base`.
7
+ * Consumers can explicitly select `base`, `react`, or `react-native` when automatic detection is
8
+ * not appropriate.
9
+ *
10
+ * Every profile includes the shared TypeScript, import, unused-import, Prettier, security, and
11
+ * quality rules. The common quality limits are 50 effective lines per function, 300 effective
12
+ * lines per file, and modified cyclomatic complexity 15. React adds React and Hooks correctness
13
+ * rules; React Native composes the React profile and adds focused React Native rules.
14
+ *
15
+ * Repository-specific behavior stays additive: `additionalIgnores`, `restrictedImports`, and
16
+ * `overrides` extend the central policy instead of replacing it. Narrow local overrides remain the
17
+ * supported migration mechanism for legacy violations while organization-wide defaults stay
18
+ * strict.
19
+ *
20
+ * @readme
21
+ */
1
22
  import js from '@eslint/js';
2
23
  import prettierConfig from 'eslint-config-prettier';
3
24
  import importPlugin from 'eslint-plugin-import';
4
25
  import prettierPlugin from 'eslint-plugin-prettier';
26
+ import reactPlugin from 'eslint-plugin-react';
27
+ import reactHooksPlugin from 'eslint-plugin-react-hooks';
28
+ import reactNativePlugin from 'eslint-plugin-react-native';
29
+ import securityPlugin from 'eslint-plugin-security';
5
30
  import simpleImportSort from 'eslint-plugin-simple-import-sort';
6
31
  import unusedImports from 'eslint-plugin-unused-imports';
7
32
  import tseslint from 'typescript-eslint';
33
+ import { resolveEslintProfile } from './profile.js';
8
34
  export const defaultIgnores = [
9
35
  '**/ios/**',
10
36
  '**/android/**',
@@ -24,79 +50,138 @@ export const defaultRestrictedImports = [
24
50
  },
25
51
  ];
26
52
  export function createConfig(options) {
27
- const normalizedOptions = {
28
- allowDefaultProject: [],
29
- additionalIgnores: [],
30
- restrictedImports: [],
31
- overrides: [],
32
- includePrettier: true,
33
- ...options,
53
+ const normalized = normalizeOptions(options);
54
+ const profile = resolveEslintProfile(options);
55
+ return tseslint.config({ ignores: [...defaultIgnores, ...normalized.additionalIgnores] }, js.configs.recommended, ...createTypeCheckedConfigs(normalized), createBaseConfig(normalized), ...createProfileConfigs(profile, normalized.files), ...normalized.overrides, ...(normalized.includePrettier ? [prettierConfig] : []));
56
+ }
57
+ function normalizeOptions(options) {
58
+ return {
59
+ tsconfigRootDir: options.tsconfigRootDir,
60
+ project: options.project,
61
+ files: options.files,
62
+ allowDefaultProject: options.allowDefaultProject ?? [],
63
+ additionalIgnores: options.additionalIgnores ?? [],
64
+ restrictedImports: [...(options.restrictedImports ?? [])],
65
+ overrides: [...(options.overrides ?? [])],
66
+ includePrettier: options.includePrettier ?? true,
34
67
  };
35
- const combinedRestrictedImports = [
36
- ...defaultRestrictedImports,
37
- ...normalizedOptions.restrictedImports,
68
+ }
69
+ function createTypeCheckedConfigs(options) {
70
+ const configs = [
71
+ ...tseslint.configs.recommendedTypeChecked,
72
+ ...tseslint.configs.stylisticTypeChecked,
38
73
  ];
39
- const plugins = {
40
- import: importPlugin,
41
- prettier: prettierPlugin,
42
- 'simple-import-sort': simpleImportSort,
43
- 'unused-imports': unusedImports,
44
- };
45
- return tseslint.config({
46
- ignores: [...defaultIgnores, ...normalizedOptions.additionalIgnores],
47
- }, js.configs.recommended, ...tseslint.configs.recommendedTypeChecked.map((config) => ({
48
- ...config,
49
- files: normalizedOptions.files,
50
- })), ...tseslint.configs.stylisticTypeChecked.map((config) => ({
51
- ...config,
52
- files: normalizedOptions.files,
53
- })), {
54
- files: normalizedOptions.files,
74
+ return configs.map((config) => ({ ...config, files: options.files }));
75
+ }
76
+ function createBaseConfig(options) {
77
+ return {
78
+ files: options.files,
55
79
  languageOptions: {
56
80
  parser: tseslint.parser,
57
81
  parserOptions: {
58
- project: normalizedOptions.project,
59
- tsconfigRootDir: normalizedOptions.tsconfigRootDir,
60
- allowDefaultProject: normalizedOptions.allowDefaultProject,
82
+ project: options.project,
83
+ tsconfigRootDir: options.tsconfigRootDir,
84
+ allowDefaultProject: options.allowDefaultProject,
85
+ ecmaFeatures: { jsx: true },
61
86
  },
62
87
  },
63
- plugins,
88
+ plugins: {
89
+ import: importPlugin,
90
+ prettier: prettierPlugin,
91
+ security: securityPlugin,
92
+ 'simple-import-sort': simpleImportSort,
93
+ 'unused-imports': unusedImports,
94
+ },
64
95
  rules: {
65
- '@typescript-eslint/no-non-null-assertion': 'error',
66
- '@typescript-eslint/prefer-readonly': 'error',
67
- '@typescript-eslint/prefer-optional-chain': 'error',
68
- '@typescript-eslint/prefer-as-const': 'error',
69
- '@typescript-eslint/no-unnecessary-type-arguments': 'error',
70
- '@typescript-eslint/no-unnecessary-condition': 'error',
71
- '@typescript-eslint/no-unnecessary-type-constraint': 'error',
72
- '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
73
- '@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
74
- '@typescript-eslint/prefer-nullish-coalescing': 'error',
75
- 'no-restricted-imports': [
76
- 'error',
77
- {
78
- paths: combinedRestrictedImports,
79
- },
80
- ],
81
- 'prefer-destructuring': 'off',
82
- '@typescript-eslint/prefer-destructuring': 'error',
83
- 'simple-import-sort/imports': 'error',
84
- 'simple-import-sort/exports': 'error',
85
- 'unused-imports/no-unused-imports': 'error',
86
- 'unused-imports/no-unused-vars': [
87
- 'error',
88
- {
89
- vars: 'all',
90
- varsIgnorePattern: '^_',
91
- args: 'after-used',
92
- argsIgnorePattern: '^_',
93
- },
94
- ],
95
- 'import/order': 'off',
96
- '@typescript-eslint/no-unused-vars': 'off',
97
- '@typescript-eslint/no-explicit-any': 'error',
98
- 'prettier/prettier': 'error',
99
- 'no-console': 'off',
96
+ ...createTypeScriptRules(),
97
+ ...createImportRules(options),
98
+ ...createQualityRules(),
99
+ ...createSecurityRules(),
100
100
  },
101
- }, ...normalizedOptions.overrides, ...(normalizedOptions.includePrettier ? [prettierConfig] : []));
101
+ };
102
+ }
103
+ function createTypeScriptRules() {
104
+ return {
105
+ '@typescript-eslint/no-non-null-assertion': 'error',
106
+ '@typescript-eslint/prefer-readonly': 'error',
107
+ '@typescript-eslint/prefer-optional-chain': 'error',
108
+ '@typescript-eslint/prefer-as-const': 'error',
109
+ '@typescript-eslint/no-unnecessary-type-arguments': 'error',
110
+ '@typescript-eslint/no-unnecessary-condition': 'error',
111
+ '@typescript-eslint/no-unnecessary-type-constraint': 'error',
112
+ '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
113
+ '@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
114
+ '@typescript-eslint/prefer-nullish-coalescing': 'error',
115
+ '@typescript-eslint/prefer-destructuring': 'error',
116
+ '@typescript-eslint/no-unused-vars': 'off',
117
+ '@typescript-eslint/no-explicit-any': 'error',
118
+ };
119
+ }
120
+ function createImportRules(options) {
121
+ return {
122
+ 'no-restricted-imports': [
123
+ 'error',
124
+ { paths: [...defaultRestrictedImports, ...options.restrictedImports] },
125
+ ],
126
+ 'prefer-destructuring': 'off',
127
+ 'simple-import-sort/imports': 'error',
128
+ 'simple-import-sort/exports': 'error',
129
+ 'unused-imports/no-unused-imports': 'error',
130
+ 'unused-imports/no-unused-vars': [
131
+ 'error',
132
+ { vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' },
133
+ ],
134
+ 'import/order': 'off',
135
+ 'prettier/prettier': 'error',
136
+ 'no-console': 'off',
137
+ };
138
+ }
139
+ function createQualityRules() {
140
+ return {
141
+ 'max-lines-per-function': ['error', { max: 50, skipBlankLines: true, skipComments: true }],
142
+ 'max-lines': ['error', { max: 300, skipBlankLines: true, skipComments: true }],
143
+ complexity: ['error', { max: 15, variant: 'modified' }],
144
+ };
145
+ }
146
+ function createSecurityRules() {
147
+ return {
148
+ 'security/detect-object-injection': 'warn',
149
+ 'security/detect-non-literal-require': 'error',
150
+ };
151
+ }
152
+ function createProfileConfigs(profile, files) {
153
+ if (profile === 'base') {
154
+ return [];
155
+ }
156
+ const reactConfig = createReactConfig(files);
157
+ return profile === 'react-native' ? [reactConfig, createReactNativeConfig(files)] : [reactConfig];
158
+ }
159
+ function createReactConfig(files) {
160
+ return {
161
+ files,
162
+ plugins: { react: reactPlugin, 'react-hooks': reactHooksPlugin },
163
+ settings: { react: { version: 'detect' } },
164
+ rules: {
165
+ 'react/no-danger': 'error',
166
+ 'react-hooks/rules-of-hooks': 'error',
167
+ 'react-hooks/exhaustive-deps': 'error',
168
+ 'react-hooks/immutability': 'error',
169
+ 'react-hooks/purity': 'error',
170
+ 'react-hooks/refs': 'error',
171
+ 'react-hooks/set-state-in-effect': 'error',
172
+ 'react-hooks/set-state-in-render': 'error',
173
+ 'react-hooks/static-components': 'error',
174
+ },
175
+ };
176
+ }
177
+ function createReactNativeConfig(files) {
178
+ return {
179
+ files,
180
+ plugins: { 'react-native': reactNativePlugin },
181
+ rules: {
182
+ 'react-native/no-inline-styles': 'warn',
183
+ 'react-native/no-unused-styles': 'error',
184
+ 'react-native/no-single-element-style-arrays': 'error',
185
+ },
186
+ };
102
187
  }
@@ -0,0 +1,10 @@
1
+ export declare const eslintManagedFiles: readonly [{
2
+ readonly relativePath: "eslint.local.config.mjs";
3
+ readonly render: typeof renderInitialLocalConfig;
4
+ readonly mode: "create-only";
5
+ }, {
6
+ readonly relativePath: "eslint.config.mjs";
7
+ readonly contents: "import { createConfig } from '@ankhorage/devtools/eslint';\nimport localConfig from './eslint.local.config.mjs';\n\nconst localEntries = Array.isArray(localConfig) ? localConfig : [localConfig];\n\nexport default [\n ...createConfig({\n files: ['src/**/*.{ts,tsx}'],\n project: ['./tsconfig.json'],\n tsconfigRootDir: import.meta.dirname,\n }),\n ...localEntries,\n];\n";
8
+ }];
9
+ declare function renderInitialLocalConfig(targetDirectory: string): Promise<string>;
10
+ export {};
@@ -0,0 +1,44 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ const ESLINT_CONFIG = `import { createConfig } from '@ankhorage/devtools/eslint';
4
+ import localConfig from './eslint.local.config.mjs';
5
+
6
+ const localEntries = Array.isArray(localConfig) ? localConfig : [localConfig];
7
+
8
+ export default [
9
+ ...createConfig({
10
+ files: ['src/**/*.{ts,tsx}'],
11
+ project: ['./tsconfig.json'],
12
+ tsconfigRootDir: import.meta.dirname,
13
+ }),
14
+ ...localEntries,
15
+ ];
16
+ `;
17
+ const EMPTY_LOCAL_CONFIG = `export default [];
18
+ `;
19
+ export const eslintManagedFiles = [
20
+ {
21
+ relativePath: 'eslint.local.config.mjs',
22
+ render: renderInitialLocalConfig,
23
+ mode: 'create-only',
24
+ },
25
+ {
26
+ relativePath: 'eslint.config.mjs',
27
+ contents: ESLINT_CONFIG,
28
+ },
29
+ ];
30
+ async function renderInitialLocalConfig(targetDirectory) {
31
+ try {
32
+ const existingConfig = await readFile(resolve(targetDirectory, 'eslint.config.mjs'), 'utf8');
33
+ return existingConfig === ESLINT_CONFIG ? EMPTY_LOCAL_CONFIG : existingConfig;
34
+ }
35
+ catch (error) {
36
+ if (isNodeError(error) && error.code === 'ENOENT') {
37
+ return EMPTY_LOCAL_CONFIG;
38
+ }
39
+ throw error;
40
+ }
41
+ }
42
+ function isNodeError(error) {
43
+ return error instanceof Error && 'code' in error;
44
+ }
@@ -0,0 +1,4 @@
1
+ import { type ProjectDetectionInput } from '@ankhorage/utility/project';
2
+ import type { DevtoolsConfigOptions, DevtoolsEslintProfile, ResolvedDevtoolsEslintProfile } from './types.js';
3
+ export declare function resolveEslintProfile(options: DevtoolsConfigOptions): ResolvedDevtoolsEslintProfile;
4
+ export declare function resolveEslintProfileFromDetectionInput(requestedProfile: DevtoolsEslintProfile, input: ProjectDetectionInput): ResolvedDevtoolsEslintProfile;
@@ -0,0 +1,81 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { detectProject, } from '@ankhorage/utility/project';
4
+ export function resolveEslintProfile(options) {
5
+ const requestedProfile = options.profile ?? 'auto';
6
+ if (requestedProfile !== 'auto') {
7
+ return requestedProfile;
8
+ }
9
+ const packageJsonPath = resolveProjectPackageJsonPath(options);
10
+ const input = packageJsonPath === null ? {} : readDetectionInput(packageJsonPath);
11
+ return resolveEslintProfileFromDetectionInput('auto', input);
12
+ }
13
+ export function resolveEslintProfileFromDetectionInput(requestedProfile, input) {
14
+ if (requestedProfile !== 'auto') {
15
+ return requestedProfile;
16
+ }
17
+ const { traits } = detectProject(input);
18
+ if (traits.has('react-native') || traits.has('expo')) {
19
+ return 'react-native';
20
+ }
21
+ return traits.has('react') ? 'react' : 'base';
22
+ }
23
+ function resolveProjectPackageJsonPath(options) {
24
+ if (options.packageJsonPath !== undefined) {
25
+ return resolve(options.tsconfigRootDir, options.packageJsonPath);
26
+ }
27
+ let directory = resolve(options.tsconfigRootDir);
28
+ for (;;) {
29
+ const candidate = resolve(directory, 'package.json');
30
+ if (existsSync(candidate)) {
31
+ return candidate;
32
+ }
33
+ const parent = dirname(directory);
34
+ if (parent === directory) {
35
+ return null;
36
+ }
37
+ directory = parent;
38
+ }
39
+ }
40
+ function readDetectionInput(packageJsonPath) {
41
+ const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
42
+ if (!isRecord(parsed)) {
43
+ throw new Error(`Expected package.json to contain a JSON object: ${packageJsonPath}`);
44
+ }
45
+ return {
46
+ ...optionalDependencyMap('dependencies', parsed.dependencies),
47
+ ...optionalDependencyMap('devDependencies', parsed.devDependencies),
48
+ ...optionalDependencyMap('peerDependencies', parsed.peerDependencies),
49
+ ...optionalEngines(parsed.engines),
50
+ ...(typeof parsed.packageManager === 'string' ? { packageManager: parsed.packageManager } : {}),
51
+ };
52
+ }
53
+ function optionalDependencyMap(key, value) {
54
+ const dependencyMap = toDependencyMap(value);
55
+ return dependencyMap === undefined ? {} : { [key]: dependencyMap };
56
+ }
57
+ function toDependencyMap(value) {
58
+ if (!isRecord(value)) {
59
+ return undefined;
60
+ }
61
+ const dependencies = {};
62
+ for (const [name, version] of Object.entries(value)) {
63
+ if (typeof version === 'string') {
64
+ dependencies[name] = version;
65
+ }
66
+ }
67
+ return dependencies;
68
+ }
69
+ function optionalEngines(value) {
70
+ if (!isRecord(value)) {
71
+ return {};
72
+ }
73
+ const engines = {
74
+ ...(typeof value.bun === 'string' ? { bun: value.bun } : {}),
75
+ ...(typeof value.node === 'string' ? { node: value.node } : {}),
76
+ };
77
+ return Object.keys(engines).length === 0 ? {} : { engines };
78
+ }
79
+ function isRecord(value) {
80
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
81
+ }
@@ -1,14 +1,18 @@
1
1
  import type tseslint from 'typescript-eslint';
2
2
  type FlatConfig = ReturnType<typeof tseslint.config>;
3
3
  export type FlatConfigItem = FlatConfig[number];
4
- interface RestrictedImport {
4
+ export interface RestrictedImport {
5
5
  readonly name: string;
6
6
  readonly message: string;
7
7
  }
8
+ export type DevtoolsEslintProfile = 'auto' | 'base' | 'react' | 'react-native';
9
+ export type ResolvedDevtoolsEslintProfile = Exclude<DevtoolsEslintProfile, 'auto'>;
8
10
  export interface DevtoolsConfigOptions {
9
11
  readonly tsconfigRootDir: string;
10
12
  readonly project: string[];
11
13
  readonly files: string[];
14
+ readonly profile?: DevtoolsEslintProfile;
15
+ readonly packageJsonPath?: string;
12
16
  readonly allowDefaultProject?: string[];
13
17
  readonly additionalIgnores?: string[];
14
18
  readonly restrictedImports?: RestrictedImport[];
@@ -1,3 +1,16 @@
1
+ /***
2
+ * Build shared Knip configuration while preserving repository-specific discovery.
3
+ *
4
+ * `createKnipConfig` keeps Knip zero-config behavior by default and only emits settings that the
5
+ * consumer explicitly provides. Repositories can add narrow entries, project globs, ignored files,
6
+ * binaries, dependencies, and workspace overrides without duplicating the shared Knip version.
7
+ *
8
+ * `createKnipMonorepoConfig` adds a root workspace plus conventional `packages/*` and `apps/*`
9
+ * workspace globs. Callers can override those globs, provide workspace defaults, and replace or
10
+ * extend individual workspace configuration deterministically.
11
+ *
12
+ * @readme
13
+ */
1
14
  import type { KnipConfig } from 'knip';
2
15
  export interface DevtoolsKnipWorkspaceConfigOptions {
3
16
  readonly entry?: string[];
@@ -0,0 +1,5 @@
1
+ export declare const knipManagedFiles: readonly [{
2
+ readonly relativePath: "knip.config.ts";
3
+ readonly contents: "import { createKnipConfig } from '@ankhorage/devtools/knip';\n\nexport default createKnipConfig();\n";
4
+ readonly mode: "create-only";
5
+ }];
@@ -0,0 +1,11 @@
1
+ const KNIP_CONFIG = `import { createKnipConfig } from '@ankhorage/devtools/knip';
2
+
3
+ export default createKnipConfig();
4
+ `;
5
+ export const knipManagedFiles = [
6
+ {
7
+ relativePath: 'knip.config.ts',
8
+ contents: KNIP_CONFIG,
9
+ mode: 'create-only',
10
+ },
11
+ ];
@@ -0,0 +1,8 @@
1
+ import type { ManagedFileStatus, ManagedFileSyncResult } from '../shared/managedFiles.js';
2
+ export declare function inspectPackageManifest(targetDirectory: string, devtoolsVersion: string): Promise<ManagedFileStatus>;
3
+ export declare function syncPackageManifest(targetDirectory: string, devtoolsVersion: string, options: {
4
+ readonly dryRun: boolean;
5
+ }): Promise<ManagedFileSyncResult>;
6
+ export declare function applyManagedPackageContract(manifest: Record<string, unknown>, devtoolsVersion: string): Record<string, unknown>;
7
+ export declare function isManagedPackageContractCurrent(manifest: Record<string, unknown>, devtoolsVersion: string): boolean;
8
+ export declare function readCurrentDevtoolsVersion(): string;
@@ -0,0 +1,152 @@
1
+ /***
2
+ * Synchronize the consumer `package.json` contract owned by `@ankhorage/devtools`.
3
+ *
4
+ * Package synchronization is merge-aware. It installs the current `@ankhorage/devtools` version
5
+ * as a development dependency, removes individually installed toolchain packages that devtools now
6
+ * owns, and writes the canonical `lint`, `lint:fix`, `format`, `format:check`, and `knip` scripts.
7
+ * Unrelated manifest fields, scripts, dependencies, and metadata are preserved.
8
+ *
9
+ * Status compares only the fields owned by this contract, so unrelated repository customization
10
+ * does not count as drift. `--dry-run` reports whether `package.json` would be created or updated
11
+ * without writing it, and repeated synchronization is idempotent.
12
+ *
13
+ * The devtools package itself is excluded from the consumer contract to avoid rewriting its own
14
+ * manifest.
15
+ *
16
+ * @readme
17
+ */
18
+ import { readFileSync } from 'node:fs';
19
+ import { readFile, writeFile } from 'node:fs/promises';
20
+ import { resolve } from 'node:path';
21
+ const PACKAGE_PATH = 'package.json';
22
+ const DEVTOOLS_PACKAGE_NAME = '@ankhorage/devtools';
23
+ const STANDARD_SCRIPTS = {
24
+ lint: 'ankhorage-eslint . --max-warnings=0',
25
+ 'lint:fix': 'ankhorage-eslint . --fix --max-warnings=0',
26
+ format: 'ankhorage-prettier --write .',
27
+ 'format:check': 'ankhorage-prettier --check .',
28
+ knip: 'ankhorage-knip',
29
+ };
30
+ const DEVTOOLS_OWNED_DEV_DEPENDENCIES = [
31
+ '@eslint/js',
32
+ 'eslint',
33
+ 'eslint-config-prettier',
34
+ 'eslint-plugin-import',
35
+ 'eslint-plugin-prettier',
36
+ 'eslint-plugin-react',
37
+ 'eslint-plugin-react-hooks',
38
+ 'eslint-plugin-react-native',
39
+ 'eslint-plugin-security',
40
+ 'eslint-plugin-simple-import-sort',
41
+ 'eslint-plugin-unused-imports',
42
+ 'knip',
43
+ 'prettier',
44
+ 'typescript-eslint',
45
+ ];
46
+ export async function inspectPackageManifest(targetDirectory, devtoolsVersion) {
47
+ const snapshot = await readPackageManifest(targetDirectory);
48
+ if (!snapshot.exists) {
49
+ return { relativePath: PACKAGE_PATH, state: 'missing' };
50
+ }
51
+ return {
52
+ relativePath: PACKAGE_PATH,
53
+ state: isManagedPackageContractCurrent(snapshot.manifest, devtoolsVersion)
54
+ ? 'current'
55
+ : 'outdated',
56
+ };
57
+ }
58
+ export async function syncPackageManifest(targetDirectory, devtoolsVersion, options) {
59
+ const snapshot = await readPackageManifest(targetDirectory);
60
+ if (snapshot.exists && isManagedPackageContractCurrent(snapshot.manifest, devtoolsVersion)) {
61
+ return { relativePath: PACKAGE_PATH, action: 'unchanged' };
62
+ }
63
+ if (options.dryRun) {
64
+ return {
65
+ relativePath: PACKAGE_PATH,
66
+ action: snapshot.exists ? 'would-update' : 'would-create',
67
+ };
68
+ }
69
+ const updatedManifest = applyManagedPackageContract(snapshot.manifest, devtoolsVersion);
70
+ await writeFile(resolve(targetDirectory, PACKAGE_PATH), serializePackageManifest(updatedManifest), 'utf8');
71
+ return {
72
+ relativePath: PACKAGE_PATH,
73
+ action: snapshot.exists ? 'updated' : 'created',
74
+ };
75
+ }
76
+ export function applyManagedPackageContract(manifest, devtoolsVersion) {
77
+ if (manifest.name === DEVTOOLS_PACKAGE_NAME) {
78
+ return manifest;
79
+ }
80
+ const scripts = { ...toRecord(manifest.scripts), ...STANDARD_SCRIPTS };
81
+ const devDependencies = removeOwnedDependencies(toRecord(manifest.devDependencies));
82
+ devDependencies[DEVTOOLS_PACKAGE_NAME] = `^${devtoolsVersion}`;
83
+ const dependencies = toRecord(manifest.dependencies);
84
+ delete dependencies[DEVTOOLS_PACKAGE_NAME];
85
+ return {
86
+ ...manifest,
87
+ ...normalizedDependencies(manifest, dependencies),
88
+ scripts,
89
+ devDependencies,
90
+ };
91
+ }
92
+ export function isManagedPackageContractCurrent(manifest, devtoolsVersion) {
93
+ if (manifest.name === DEVTOOLS_PACKAGE_NAME) {
94
+ return true;
95
+ }
96
+ const scripts = toRecord(manifest.scripts);
97
+ const devDependencies = toRecord(manifest.devDependencies);
98
+ const dependencies = toRecord(manifest.dependencies);
99
+ return (hasStandardScripts(scripts) &&
100
+ DEVTOOLS_OWNED_DEV_DEPENDENCIES.every((name) => devDependencies[name] === undefined) &&
101
+ devDependencies[DEVTOOLS_PACKAGE_NAME] === `^${devtoolsVersion}` &&
102
+ dependencies[DEVTOOLS_PACKAGE_NAME] === undefined);
103
+ }
104
+ export function readCurrentDevtoolsVersion() {
105
+ const parsed = JSON.parse(readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'));
106
+ if (!isRecord(parsed) || typeof parsed.version !== 'string' || parsed.version.trim() === '') {
107
+ throw new Error('Devtools package.json must define a non-empty version string.');
108
+ }
109
+ return parsed.version;
110
+ }
111
+ async function readPackageManifest(targetDirectory) {
112
+ try {
113
+ const contents = await readFile(resolve(targetDirectory, PACKAGE_PATH), 'utf8');
114
+ const parsed = JSON.parse(contents);
115
+ if (!isRecord(parsed)) {
116
+ throw new Error(`Expected ${PACKAGE_PATH} to contain a JSON object.`);
117
+ }
118
+ return { exists: true, manifest: parsed };
119
+ }
120
+ catch (error) {
121
+ if (isNodeError(error) && error.code === 'ENOENT') {
122
+ return { exists: false, manifest: {} };
123
+ }
124
+ throw error;
125
+ }
126
+ }
127
+ function removeOwnedDependencies(devDependencies) {
128
+ for (const dependencyName of DEVTOOLS_OWNED_DEV_DEPENDENCIES) {
129
+ delete devDependencies[dependencyName];
130
+ }
131
+ return devDependencies;
132
+ }
133
+ function normalizedDependencies(manifest, dependencies) {
134
+ return Object.keys(dependencies).length === 0 && manifest.dependencies === undefined
135
+ ? {}
136
+ : { dependencies };
137
+ }
138
+ function hasStandardScripts(scripts) {
139
+ return Object.entries(STANDARD_SCRIPTS).every(([name, command]) => scripts[name] === command);
140
+ }
141
+ function serializePackageManifest(manifest) {
142
+ return `${JSON.stringify(manifest, null, 2)}\n`;
143
+ }
144
+ function toRecord(value) {
145
+ return isRecord(value) ? { ...value } : {};
146
+ }
147
+ function isRecord(value) {
148
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
149
+ }
150
+ function isNodeError(error) {
151
+ return error instanceof Error && 'code' in error;
152
+ }
@@ -0,0 +1,6 @@
1
+ export declare const prettierManagedFiles: readonly [{
2
+ readonly relativePath: ".prettierrc.js";
3
+ readonly render: typeof renderPrettierConfig;
4
+ }];
5
+ declare function renderPrettierConfig(targetDirectory: string): Promise<string>;
6
+ export {};
@@ -0,0 +1,34 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ const ESM_CONFIG = `export { default } from '@ankhorage/devtools/prettier';
4
+ `;
5
+ const COMMONJS_CONFIG = `module.exports = require('@ankhorage/devtools/prettier');
6
+ `;
7
+ export const prettierManagedFiles = [
8
+ {
9
+ relativePath: '.prettierrc.js',
10
+ render: renderPrettierConfig,
11
+ },
12
+ ];
13
+ async function renderPrettierConfig(targetDirectory) {
14
+ return (await readPackageType(targetDirectory)) === 'module' ? ESM_CONFIG : COMMONJS_CONFIG;
15
+ }
16
+ async function readPackageType(targetDirectory) {
17
+ try {
18
+ const contents = await readFile(resolve(targetDirectory, 'package.json'), 'utf8');
19
+ const parsed = JSON.parse(contents);
20
+ return isRecord(parsed) && typeof parsed.type === 'string' ? parsed.type : undefined;
21
+ }
22
+ catch (error) {
23
+ if (isNodeError(error) && error.code === 'ENOENT') {
24
+ return undefined;
25
+ }
26
+ throw error;
27
+ }
28
+ }
29
+ function isRecord(value) {
30
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
31
+ }
32
+ function isNodeError(error) {
33
+ return error instanceof Error && 'code' in error;
34
+ }
@@ -1,6 +1,11 @@
1
+ type ManagedFileMode = 'create-only' | 'replace';
2
+ type ManagedFileRenderer = (targetDirectory: string) => Promise<string> | string;
1
3
  export interface ManagedFileDefinition {
2
4
  readonly relativePath: string;
3
- readonly sourceUrl: URL;
5
+ readonly sourceUrl?: URL;
6
+ readonly contents?: string;
7
+ readonly render?: ManagedFileRenderer;
8
+ readonly mode?: ManagedFileMode;
4
9
  }
5
10
  type ManagedFileState = 'current' | 'missing' | 'outdated';
6
11
  type ManagedFileSyncAction = 'unchanged' | 'created' | 'updated' | 'would-create' | 'would-update';