Files
client-sdk-flutter/scripts/create_change.dart
2026-03-23 20:45:16 +09:00

225 lines
6.4 KiB
Dart
Executable File

#!/usr/bin/env dart
/*
* Copyright 2025 LiveKit
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:io';
/// Creates a new change entry in the .changes directory.
///
/// Usage:
/// dart scripts/create_change.dart
/// dart scripts/create_change.dart --level patch --type fixed --name my-fix --description "Fix something"
///
/// Interactive mode (no args) will prompt for each field with arrow-key selection.
/// CLI mode allows passing all fields as arguments.
///
/// Change file format:
/// level type="kind" "description"
///
/// Examples:
/// patch type="fixed" "Fix audio frame generation when publishing"
/// minor type="added" "Add support for custom audio processing"
/// major type="changed" "Breaking: Rename Room.connect() to Room.join()"
// ANSI escape codes
const _esc = '\x1B[';
const _reset = '\x1B[0m';
const _bold = '\x1B[1m';
const _dim = '\x1B[2m';
const _green = '\x1B[32m';
const _cyan = '\x1B[36m';
const _hideCursor = '\x1B[?25l';
const _showCursor = '\x1B[?25h';
const levels = ['patch', 'minor', 'major'];
const levelDescriptions = {
'patch': 'Bug fixes, no API changes',
'minor': 'New features, backwards compatible',
'major': 'Breaking changes',
};
const types = [
'added',
'changed',
'fixed',
'refactor',
'performance',
'security',
'deprecated',
'removed',
'docs',
];
/// Read a single raw keypress (handles arrow keys as 3-byte escape sequences).
List<int> readKey() {
stdin.echoMode = false;
stdin.lineMode = false;
try {
final first = stdin.readByteSync();
if (first == 27) {
// Escape sequence
final second = stdin.readByteSync();
if (second == 91) {
final third = stdin.readByteSync();
return [27, 91, third];
}
return [27, second];
}
return [first];
} finally {
stdin.lineMode = true;
stdin.echoMode = true;
}
}
/// Interactive picker with arrow keys. Returns selected index.
String pick(String label, List<String> options, {Map<String, String>? descriptions}) {
var selected = 0;
void render() {
stdout.write(_hideCursor);
for (var i = 0; i < options.length; i++) {
stdout.write('${_esc}2K'); // Clear entire line
if (i == selected) {
final desc = descriptions?[options[i]];
final suffix = desc != null ? ' $_dim- $desc$_reset' : '';
stdout.writeln(' $_cyan>$_reset $_bold${options[i]}$_reset$suffix');
} else {
stdout.writeln(' ${options[i]}');
}
}
}
stdout.writeln('$_bold$_green?$_reset $_bold$label$_reset ${_dim}(arrow keys, enter to confirm)$_reset');
render();
while (true) {
final key = readKey();
if (key.length == 3 && key[0] == 27 && key[1] == 91) {
if (key[2] == 65) {
// Up arrow
selected = (selected - 1) % options.length;
if (selected < 0) selected = options.length - 1;
} else if (key[2] == 66) {
// Down arrow
selected = (selected + 1) % options.length;
}
} else if (key.length == 1 && (key[0] == 10 || key[0] == 13)) {
// Enter
stdout.write(_showCursor);
// Clear the list and show selection
stdout.write('${_esc}${options.length}A'); // Move up
stdout.write('${_esc}0J'); // Clear from cursor down
stdout.writeln(' $_green>${_reset} $_bold${options[selected]}$_reset');
return options[selected];
} else if (key.length == 1 && (key[0] == 3 || key[0] == 27)) {
// Ctrl+C or Escape
stdout.write(_showCursor);
exit(0);
}
// Redraw
stdout.write('${_esc}${options.length}A'); // Move up to start of list
render();
}
}
/// Interactive text input with prompt.
String input(String label) {
stdout.write('$_bold$_green?$_reset $_bold$label$_reset ');
final value = stdin.readLineSync()?.trim() ?? '';
if (value.isEmpty) {
stderr.writeln('Input required.');
exit(1);
}
return value;
}
String slugify(String text) {
return text.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '-').replaceAll(RegExp(r'^-|-$'), '');
}
void main(List<String> args) {
final changesDir = Directory('.changes');
if (!changesDir.existsSync()) {
changesDir.createSync();
}
String? level;
String? type;
String? name;
String? description;
// Parse CLI args
for (var i = 0; i < args.length; i++) {
switch (args[i]) {
case '--level':
case '-l':
level = args[++i];
case '--type':
case '-t':
type = args[++i];
case '--name':
case '-n':
name = args[++i];
case '--description':
case '-d':
description = args[++i];
case '--help':
case '-h':
stdout.writeln('Usage: dart scripts/create_change.dart [options]');
stdout.writeln();
stdout.writeln('Options:');
stdout.writeln(' -l, --level Version bump level (${levels.join(', ')})');
stdout.writeln(' -t, --type Change type (${types.join(', ')})');
stdout.writeln(' -n, --name File name slug (e.g. fix-svc-dynacast)');
stdout.writeln(' -d, --description Change description');
stdout.writeln(' -h, --help Show this help');
exit(0);
}
}
// Interactive prompts for missing fields
level ??= pick('Level:', levels, descriptions: levelDescriptions);
type ??= pick('Type:', types);
name ??= slugify(input('Name (slug):'));
description ??= input('Description:');
// Validate
if (!levels.contains(level)) {
stderr.writeln('Invalid level: $level');
exit(1);
}
if (!types.contains(type)) {
stderr.writeln('Invalid type: $type');
exit(1);
}
final content = '$level type="$type" "$description"\n';
final file = File('.changes/$name');
if (file.existsSync()) {
stderr.writeln('Change file already exists: .changes/$name');
exit(1);
}
file.writeAsStringSync(content);
stdout.writeln();
stdout.writeln('$_green\u2713$_reset Created $_bold.changes/$name$_reset');
stdout.writeln(' ${_dim}${content.trim()}$_reset');
}