Add release pipeline and upgrade toolchain to TypeScript 7
Publish snapshot / snapshot (push) Failing after 1m58s

Publishing infrastructure
- Three Gitea workflows: ci.yml (PRs, non-main pushes), publish-snapshot.yml
  (main -> Gitea under dist-tag @main) and release.yml (tags -> Gitea + npmjs)
- Tag-driven releases as <package-dir>-v<version>; the manifest stays the
  source of truth and release.yml refuses to run if tag and manifest disagree
- Every publish is idempotent: each step checks the registry first, so a run
  that fails on the second registry can simply be re-run
- Hard coverage gate (85% branches) shared by CI, the pre-push hook and local
  runs, since the thresholds live in vitest.config.ts rather than a CI flag
- README.PUBLISH.md documents the whole mechanism

Toolchain
- TypeScript 7 native compiler; drop tsgo and ts-node, use tsx for dev runs
- Biome 1.9 -> 2.x, Vitest 1 -> 4, zod 3 -> 4, inquirer 8 -> 14, pnpm 11.17.0
- Replace inquirer-checkbox-plus-prompt, which is peer-capped at inquirer <9,
  with enquirer's AutoComplete; the CubeSelection contract is unchanged
- Stand in for zod 4's removed z.AnyZodObject with a local AnyObjectSchema

Repo hygiene
- Stop tracking dist/; ignore coverage/, *.tsbuildinfo, .npmrc* and release.json
- Drop package-lock.json in favour of pnpm-lock.yaml

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Diedrichsen
2026-07-27 15:17:14 +02:00
parent 736c01216a
commit 587ff2cf47
126 changed files with 6065 additions and 7544 deletions
+10 -6
View File
@@ -8,8 +8,8 @@ import type { Variables } from '../nopy.common.js';
import type { NopyConfig } from '../nopy.config.js';
import type { DeployCall } from '../nopy.executor.js';
import { VariableAssignment } from '../nopy.prompts.js';
import { type CubeSession, type NopySession } from '../nopy.session.js';
import type { Cube, CubeVariables, DependencySpec, HookContext } from './types.js';
import type { CubeSession, NopySession } from '../nopy.session.js';
import type { Cube, CubeVariables, HookContext } from './types.js';
const log = getLogger(['nopy', 'resolution']);
@@ -40,7 +40,11 @@ export class BuildContext {
/**
* Resolves a cube, its dependencies, and hooks recursively
*/
public async resolveCube(cubeId: string, host: string, overrides: CubeVariables = {}): Promise<void> {
public async resolveCube(
cubeId: string,
host: string,
overrides: CubeVariables = {}
): Promise<void> {
const cube = this.allCubes[cubeId];
if (!cube) {
throw new Error(`Cube not found: ${cubeId}`);
@@ -56,7 +60,7 @@ export class BuildContext {
// 2. Variable collection
if (this.options.isSessionReplay) {
const sessionCube = this.session.cubes.find(c => c.key === cubeId);
const sessionCube = this.session.cubes.find((c) => c.key === cubeId);
if (sessionCube) {
this.variables.assign(cubeId, 'defaults', sessionCube.variables);
}
@@ -101,7 +105,7 @@ export class BuildContext {
private buildDeployCall(cube: Cube, host: string): void {
const cubeId = cube.id;
const callKey = `${cubeId}:${host}`;
if (this.resolvedCubes.has(callKey)) return;
const parts: string[] = [];
@@ -128,7 +132,7 @@ export class BuildContext {
dependencies: [],
});
if (!this.cubeSessions.some(s => s.key === cubeId)) {
if (!this.cubeSessions.some((s) => s.key === cubeId)) {
this.cubeSessions.push({
key: cubeId,
variables: this.variables.get(cubeId, 'prompts'),
+2 -2
View File
@@ -3,7 +3,7 @@
* @module cubes/factories
*/
import { Manifest } from './types.js';
import { type AnyObjectSchema, Manifest } from './types.js';
/**
* Creates a manifest configuration for a cube
@@ -11,7 +11,7 @@ import { Manifest } from './types.js';
* @param opts - Manifest options including name, schema, dependencies, and hooks
* @returns Manifest configuration object
*/
export function createManifest<Schema extends import('zod').z.AnyZodObject>(
export function createManifest<Schema extends AnyObjectSchema>(
opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>
): Manifest<Schema> {
return Manifest(opts);
+21 -26
View File
@@ -6,37 +6,32 @@
* @module cubes
*/
// Dependencies
export { BuildContext } from './dependencies.js';
// Factory functions
export {
createManifest,
manifest,
} from './factories.js';
// Loader
export {
findCubeDirectories,
getCube,
loadCubes,
} from './loader.js';
export type {
AnyObjectSchema,
CubeVariables,
DependencySpec,
Hook,
HookContext,
LoadResult,
} from './types.js';
// Types
export {
Cube,
Manifest,
} from './types.js';
export type {
Hook,
HookContext,
LoadResult,
CubeVariables,
DependencySpec,
} from './types.js';
// Factory functions
export {
createManifest,
manifest,
} from './factories.js';
// Loader
export {
loadCubes,
findCubeDirectories,
getCube,
} from './loader.js';
// Dependencies
export {
BuildContext,
} from './dependencies.js';
// Utilities
export { uniqid } from './utils.js';
+1 -1
View File
@@ -80,7 +80,7 @@ export async function loadCubes(): Promise<LoadResult> {
errors.push(`Invalid manifest format in ${manifestPath}: missing 'name'`);
} else {
const cubeId = extractCubeId(manifest) || path.basename(cubePath);
if (cubes[cubeId]) {
errors.push(`Duplicate cube id '${cubeId}'`);
return;
+12 -5
View File
@@ -5,6 +5,13 @@
import { z } from 'zod';
/**
* Any object schema, whatever its shape.
*
* Stands in for zod 3's `z.AnyZodObject`, which zod 4 removed.
*/
export type AnyObjectSchema = z.ZodObject<Record<string, z.ZodType<any>>>;
/**
* Variables that can be passed to a cube
*/
@@ -25,7 +32,7 @@ export interface HookContext {
/**
* Hook function type for before/after cube execution
*/
export type Hook<Schema extends z.AnyZodObject = z.AnyZodObject> = (
export type Hook<Schema extends AnyObjectSchema = AnyObjectSchema> = (
ctx: HookContext,
variables: z.infer<Schema>
) => void | Promise<void>;
@@ -33,7 +40,7 @@ export type Hook<Schema extends z.AnyZodObject = z.AnyZodObject> = (
/**
* User-defined specification for a cube
*/
export interface Manifest<Schema extends z.AnyZodObject = z.AnyZodObject> {
export interface Manifest<Schema extends AnyObjectSchema = AnyObjectSchema> {
/** Unique identifier for the cube (used for dependency references) */
id: string;
/** Human-readable name of the cube */
@@ -51,7 +58,7 @@ export interface Manifest<Schema extends z.AnyZodObject = z.AnyZodObject> {
/**
* Factory function and namespace for Manifest
*/
export function Manifest<Schema extends z.AnyZodObject>(
export function Manifest<Schema extends AnyObjectSchema>(
opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>
): Manifest<Schema> {
return {
@@ -68,7 +75,7 @@ export namespace Manifest {
/**
* Internal create helper
*/
export function create<Schema extends z.AnyZodObject>(
export function create<Schema extends AnyObjectSchema>(
opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>
): Manifest<Schema> {
return Manifest(opts);
@@ -78,7 +85,7 @@ export namespace Manifest {
/**
* A fully loaded cube with its filesystem location and runtime state
*/
export class Cube<Schema extends z.AnyZodObject = z.AnyZodObject> {
export class Cube<Schema extends AnyObjectSchema = AnyObjectSchema> {
constructor(
public readonly manifest: Manifest<Schema>,
public readonly dir: string,
+56 -64
View File
@@ -6,81 +6,73 @@
// Cubes module
export * from './cubes/index.js';
export type {
ExecutionConfig,
HistoryConfig,
LogConfig,
LogVerbosity,
NopyConfig,
NopyConfigFile,
ResolutionConfig,
ResolutionStrategy,
} from './nopy.config.js';
// Configuration
export { getConfigPaths, loadConfig, logConfigToFlags, saveConfig } from './nopy.config.js';
// Backwards compatibility - cubes namespace
export { cubes } from './nopy.cubes.js';
// Main entry point
export { nopy } from './nopy.main.js';
export type { NopyOptions, NopyResult } from './nopy.main.js';
export type {
DeployCall,
ExecutionOptions,
ExecutionResult,
} from './nopy.executor.js';
// Executor
export {
executeDeployCalls,
outputExecutionPlan,
summarizeResults,
} from './nopy.executor.js';
export type {
DeployCall,
ExecutionResult,
ExecutionOptions,
} from './nopy.executor.js';
export type { HistoryEntry, SessionHistory } from './nopy.history.js';
// History management
export {
addToHistory,
clearHistory,
DEFAULT_HISTORY_SIZE,
formatHistoryList,
getHistoryPath,
getLastSession,
getSessionById,
HISTORY_FILE,
listHistory,
loadHistory,
removeFromHistory,
saveHistory,
} from './nopy.history.js';
export type { NopyOptions, NopyResult } from './nopy.main.js';
// Main entry point
export { nopy } from './nopy.main.js';
// Prompts
export {
AuthSelection,
CubeSelection,
HostSelection,
PasswordSelection,
VariableAssignment,
} from './nopy.prompts.js';
export type { AuthSession, CubeSession, NopySession } from './nopy.session.js';
// Session management
export {
createSession,
filterInternalVariables,
listSessions,
loadSession,
saveSession,
separateEnvAndCubeVariables,
} from './nopy.session.js';
export type { WorkflowOptions, WorkflowResult } from './nopy.workflow.js';
// Workflow
export {
runWorkflow,
runInteractiveWorkflow,
runReplayWorkflow,
runSessionReplayWorkflow,
runWorkflow,
} from './nopy.workflow.js';
export type { WorkflowOptions, WorkflowResult } from './nopy.workflow.js';
// Prompts
export {
CubeSelection,
AuthSelection,
HostSelection,
VariableAssignment,
PasswordSelection,
} from './nopy.prompts.js';
// Session management
export {
loadSession,
saveSession,
createSession,
listSessions,
filterInternalVariables,
separateEnvAndCubeVariables,
} from './nopy.session.js';
export type { NopySession, CubeSession, AuthSession } from './nopy.session.js';
// History management
export {
loadHistory,
saveHistory,
addToHistory,
getLastSession,
getSessionById,
listHistory,
clearHistory,
removeFromHistory,
formatHistoryList,
getHistoryPath,
DEFAULT_HISTORY_SIZE,
HISTORY_FILE,
} from './nopy.history.js';
export type { HistoryEntry, SessionHistory } from './nopy.history.js';
// Configuration
export { loadConfig, saveConfig, logConfigToFlags, getConfigPaths } from './nopy.config.js';
export type {
NopyConfig,
NopyConfigFile,
LogConfig,
LogVerbosity,
HistoryConfig,
ExecutionConfig,
ResolutionStrategy,
ResolutionConfig,
} from './nopy.config.js';
+6 -4
View File
@@ -5,6 +5,7 @@
* @module nopy.cli
*/
import { createRequire } from 'node:module';
import { Command } from 'commander';
import { loadConfig } from './nopy.config.js';
import {
@@ -16,12 +17,13 @@ import {
} from './nopy.history.js';
import { nopy } from './nopy.main.js';
const { version } = createRequire(import.meta.url)('../package.json') as { version: string };
const program = new Command();
const config = loadConfig();
program
.name('nopy')
.version('1.0.0')
.version(version)
.description('A CLI tool for pyinfra script management and execution.')
.addHelpText(
'after',
@@ -61,8 +63,8 @@ program
.option('-j, --json', 'Output results as JSON')
.option('--no-history', 'Do not save this session to history')
.action(async (options) => {
// Apply config defaults
const execConfig = config.execution ?? {};
// Loaded lazily so that --help/--version work outside a configured project.
const execConfig = loadConfig().execution ?? {};
const continueOnError = options.continueOnError ?? execConfig.continueOnError ?? false;
try {
+3 -3
View File
@@ -20,10 +20,10 @@ export const cubes = {
// Re-export types for direct access
export type {
Cube,
CubeVariables,
Hook,
HookContext,
Cube,
Manifest,
LoadResult,
CubeVariables,
Manifest,
} from './cubes/index.js';
+17 -8
View File
@@ -3,13 +3,13 @@
* @module nopy.main
*/
import { type LogRecord, configure, getAnsiColorFormatter, getLogger } from '@logtape/logtape';
import { loadCubes } from './cubes/index.js';
import { configure, getAnsiColorFormatter, getLogger, type LogRecord } from '@logtape/logtape';
import { BuildContext } from './cubes/dependencies.js';
import { loadCubes } from './cubes/index.js';
import { Variables } from './nopy.common.js';
import { getConfigPaths, loadConfig } from './nopy.config.js';
import { type ExecutionResult, executeDeployCalls, summarizeResults } from './nopy.executor.js';
import { DEFAULT_HISTORY_SIZE, addToHistory } from './nopy.history.js';
import { addToHistory, DEFAULT_HISTORY_SIZE } from './nopy.history.js';
import { type NopySession, saveSession } from './nopy.session.js';
import { runWorkflow } from './nopy.workflow.js';
@@ -34,12 +34,12 @@ function configureLogtape(): void {
loggers: [
{
category: ['logtape', 'meta'],
level: 'error',
lowestLevel: 'error',
sinks: ['console'],
},
{
category: 'nopy',
level: 'debug',
lowestLevel: 'debug',
sinks: ['console'],
},
],
@@ -52,7 +52,10 @@ configureLogtape();
/**
* Prints the active configuration summary
*/
function printActiveConfig(config: import('./nopy.config.js').NopyConfig, opts: { continueOnError: boolean }): void {
function printActiveConfig(
config: import('./nopy.config.js').NopyConfig,
opts: { continueOnError: boolean }
): void {
const configPaths = getConfigPaths();
const cwd = process.cwd();
@@ -143,12 +146,18 @@ export async function nopy(opts: NopyOptions = {}): Promise<NopyResult | undefin
if (errors.length > 0) {
log.error('Errors found during cube loading:');
errors.forEach((error) => log.error(error));
for (const error of errors) log.error(error);
if (jsonOutput) console.log(JSON.stringify({ success: false, errors }, null, 2));
return undefined;
}
const workflow = await runWorkflow(loadSessionPath, cubes, config, { useDefaults, useAuthKey }, replaySession);
const workflow = await runWorkflow(
loadSessionPath,
cubes,
config,
{ useDefaults, useAuthKey },
replaySession
);
// Step 3: Build deployment calls using BuildContext
const context = new BuildContext(
+36 -34
View File
@@ -3,23 +3,31 @@
* @module nopy.prompts
*/
// @ts-ignore - no types available
import Enquirer from 'enquirer';
import fuzzy from 'fuzzy';
import inquirer from 'inquirer';
// @ts-ignore - no types available
import CheckboxPlus from 'inquirer-checkbox-plus-prompt';
import { z } from 'zod';
import type { Cube } from './cubes/index.js';
import type { AnyObjectSchema, Cube } from './cubes/index.js';
import type { Variables } from './nopy.common.js';
// Register the checkbox-plus prompt type for filterable multi-select
inquirer.registerPrompt('checkbox-plus', CheckboxPlus);
interface CubeChoice {
/** Submitted value — enquirer returns the `name` of each selected choice. */
name: string;
value: string;
short: string;
/** Label rendered in the list. */
message: string;
}
/**
* Fuzzy-filters the cube list against what the user has typed so far.
*
* Handed to enquirer as `suggest`, which calls it on every keystroke with the
* current input and the full choice list.
*/
function suggestCubes(input: string | undefined, choices: CubeChoice[]): CubeChoice[] {
if (!input) return choices;
return fuzzy
.filter(input, choices, { extract: (choice: CubeChoice) => choice.message })
.map((result) => result.original);
}
/**
@@ -31,9 +39,8 @@ export async function CubeSelection(
const cubeChoices: CubeChoice[] = Object.values(cubes)
.sort((a, b) => a.id.localeCompare(b.id))
.map((cube) => ({
name: `${cube.id} - ${cube.name}`,
value: cube.id,
short: cube.id,
name: cube.id,
message: `${cube.id} - ${cube.name}`,
}));
// Clear terminal and move cursor to top
@@ -45,26 +52,21 @@ export async function CubeSelection(
console.log('\n Cube Selection\n');
console.log(' Type to filter • Space to select • Enter to confirm\n');
const answers = await inquirer.prompt([
{
type: 'checkbox-plus',
name: 'selectedCubes',
message: 'Select cubes:',
pageSize,
highlight: true,
searchable: true,
source: (_answersSoFar: unknown, input: string | undefined) => {
const searchTerm = input || '';
if (!searchTerm) return Promise.resolve(cubeChoices);
const results = fuzzy.filter(searchTerm, cubeChoices, {
extract: (choice: CubeChoice) => choice.name,
});
return Promise.resolve(results.map((r) => r.original));
},
},
]);
const prompt = new (Enquirer as any).AutoComplete({
name: 'selectedCubes',
message: 'Select cubes:',
limit: pageSize,
multiple: true,
choices: cubeChoices,
suggest: suggestCubes,
});
return { selectedCubes: answers.selectedCubes };
try {
return { selectedCubes: await prompt.run() };
} catch {
// User cancelled
return { selectedCubes: [] };
}
}
export async function AuthSelection(useAuthKey?: boolean): Promise<{
@@ -140,7 +142,7 @@ export async function HostSelection(hosts: string[]): Promise<string> {
return selectedHost.customHost ?? selectedHost.host;
}
function coerceValue(value: unknown, zodType: z.ZodTypeAny): unknown {
function coerceValue(value: unknown, zodType: z.core.$ZodType): unknown {
if (typeof value !== 'string') return value;
if (zodType instanceof z.ZodDefault) return coerceValue(value, zodType._def.innerType);
if (zodType instanceof z.ZodOptional) return coerceValue(value, zodType._def.innerType);
@@ -162,14 +164,14 @@ interface FormChoice {
initial: string;
}
export async function VariableAssignment<S extends z.AnyZodObject>(
export async function VariableAssignment<S extends AnyObjectSchema>(
cube: Cube<S>,
variables: Variables
) {
const schema = cube.manifest.schema.shape;
const defaults = cube.getDefaults();
const variablesToConfigure: Record<string, unknown> = {};
for (const [key, defaultValue] of Object.entries(defaults)) {
if (variables.get(cube.id, 'params')[key] === undefined) {
variablesToConfigure[key] = defaultValue;
+1 -1
View File
@@ -7,7 +7,7 @@ import { getLogger } from '@logtape/logtape';
import type { Cube } from './cubes/index.js';
import type { NopyConfig } from './nopy.config.js';
import { AuthSelection, CubeSelection, HostSelection, PasswordSelection } from './nopy.prompts.js';
import { type AuthSession, type NopySession, createSession, loadSession } from './nopy.session.js';
import { type AuthSession, createSession, loadSession, type NopySession } from './nopy.session.js';
const log = getLogger(['nopy', 'workflow']);