Lewati ke konten

Periksa YAML

Pengganti check-config.mjs tanpa paket yaml.

Berkas: alat/hari-3/Praktik 11/tools/periksa-yaml.mjs

Praktik 11/tools/periksa-yaml.mjs
#!/usr/bin/env node
// =====================================================================
// Praktik 11 - periksa-yaml.mjs
//
// Pengganti scripts/check-config.mjs dari modul, yang TIDAK BISA JALAN
// pada project apa adanya.
//
// Masalahnya: check-config.mjs memuat paket `yaml`
// const YAML = createRequire(import.meta.url)('yaml');
// tetapi `yaml` tidak ada di daftar dependencies package.json project.
// Modul Praktik 11 meminta menjalankan
// node scripts/check-config.mjs
// dan mengharapkan keluaran tertentu, padahal perintah itu berhenti dengan
// Error: Cannot find module 'yaml'
// kecuali peserta lebih dahulu menjalankan `npm install yaml` — langkah
// yang tidak pernah disebutkan di modul.
//
// Skrip ini memeriksa hal yang sama tanpa dependensi apa pun.
//
// Pakai:
// node periksa-yaml.mjs docker-compose.yml cloudbuild.yaml
// node periksa-yaml.mjs # memakai dua nama berkas bawaan
// =====================================================================
import { readFileSync, existsSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
// ---------------------------------------------------------------------
// Parser YAML minimal: cukup untuk struktur docker-compose.yml dan
// cloudbuild.yaml. Menangani pemetaan bersarang lewat indentasi, daftar
// "- item", dan blok skalar (| dan >). Tidak menangani anchor/alias,
// tag, atau aliran JSON satu baris yang rumit — dan memang tidak perlu.
// ---------------------------------------------------------------------
export function parseYaml(teks) {
const akar = {};
const tumpukan = [{ indentasi: -1, objek: akar, jenis: 'map' }];
const barisSemua = teks.replace(/\r\n?/g, '\n').split('\n');
const masalah = [];
for (let i = 0; i < barisSemua.length; i++) {
const mentah = barisSemua[i];
if (!mentah.trim() || mentah.trim().startsWith('#')) continue;
if (mentah.trim() === '---') continue;
const indentasi = mentah.length - mentah.trimStart().length;
const isi = mentah.trim();
// Naik ke induk yang sesuai.
while (tumpukan.length > 1 && indentasi <= tumpukan[tumpukan.length - 1].indentasi) {
tumpukan.pop();
}
const simpul = tumpukan[tumpukan.length - 1];
// Item daftar.
if (isi.startsWith('- ') || isi === '-') {
const nilai = isi.slice(1).trim();
// "- |" atau "- >": item daftar berupa blok skalar. Ini bentuk yang
// dipakai cloudbuild.yaml untuk args, dan tanpa penanganan khusus
// seluruh isi perintah SSH akan dianggap sebagai YAML yang rusak.
if (/^[|>][-+]?$/.test(nilai)) {
const lipat = nilai.startsWith('>');
const potongan = [];
let j = i + 1;
let indentasiBlok = -1;
while (j < barisSemua.length) {
const b = barisSemua[j];
if (!b.trim()) {
if (indentasiBlok > -1) potongan.push('');
j++;
continue;
}
const ind = b.length - b.trimStart().length;
if (ind <= indentasi) break;
if (indentasiBlok === -1) indentasiBlok = ind;
potongan.push(b.slice(indentasiBlok));
j++;
}
i = j - 1;
const teksBlok = lipat ? potongan.join(' ') : potongan.join('\n');
if (Array.isArray(simpul.objek)) simpul.objek.push(teksBlok);
continue;
}
if (!Array.isArray(simpul.objek)) {
masalah.push(`baris ${i + 1}: item daftar di luar konteks daftar`);
continue;
}
if (nilai === '') {
const wadah = {};
simpul.objek.push(wadah);
tumpukan.push({ indentasi, objek: wadah, jenis: 'map' });
} else if (nilai.includes(': ')) {
// "- key: value" -> objek di dalam daftar
const pisah = nilai.indexOf(':');
const wadah = { [nilai.slice(0, pisah).trim()]: nilai.slice(pisah + 1).trim().replace(/^["']|["']$/g, '') };
simpul.objek.push(wadah);
tumpukan.push({ indentasi, objek: wadah, jenis: 'map' });
} else {
simpul.objek.push(nilai.replace(/^["']|["']$/g, ''));
}
continue;
}
// "key:" atau "key: value"
const pisah = isi.indexOf(':');
if (pisah === -1) {
masalah.push(`baris ${i + 1}: bukan pemetaan maupun item daftar -> ${isi.slice(0, 40)}`);
continue;
}
if (Array.isArray(simpul.objek)) {
masalah.push(`baris ${i + 1}: kunci di dalam daftar tanpa tanda "-"`);
continue;
}
const kunci = isi.slice(0, pisah).trim().replace(/^["']|["']$/g, '');
let nilai = isi.slice(pisah + 1).trim();
// Komentar di akhir baris (hanya bila tidak di dalam tanda kutip).
if (!/^["']/.test(nilai)) {
const tandaKomentar = nilai.indexOf(' #');
if (tandaKomentar > -1) nilai = nilai.slice(0, tandaKomentar).trim();
}
if (nilai === '|' || nilai === '>' || nilai === '|-' || nilai === '>-' || nilai === '|+') {
const lipat = nilai.startsWith('>');
const potongan = [];
let j = i + 1;
let indentasiBlok = -1;
while (j < barisSemua.length) {
const b = barisSemua[j];
// Baris kosong selalu bagian dari blok.
if (!b.trim()) {
if (indentasiBlok > -1) potongan.push('');
j++;
continue;
}
const ind = b.length - b.trimStart().length;
if (ind <= indentasi) break;
if (indentasiBlok === -1) indentasiBlok = ind;
potongan.push(b.slice(indentasiBlok));
j++;
}
i = j - 1;
simpul.objek[kunci] = lipat ? potongan.join(' ') : potongan.join('\n');
continue;
}
if (nilai === '') {
// Tentukan apakah anaknya daftar atau pemetaan.
let j = i + 1;
let jenisAnak = null;
while (j < barisSemua.length) {
const b = barisSemua[j];
if (!b.trim() || b.trim().startsWith('#')) { j++; continue; }
const ind = b.length - b.trimStart().length;
if (ind <= indentasi) break;
jenisAnak = b.trim().startsWith('- ') || b.trim() === '-' ? 'list' : 'map';
break;
}
const wadah = jenisAnak === 'list' ? [] : {};
simpul.objek[kunci] = wadah;
if (jenisAnak) tumpukan.push({ indentasi, objek: wadah, jenis: jenisAnak });
continue;
}
// Buang tanda kutip pembungkus.
if ((nilai.startsWith('"') && nilai.endsWith('"')) || (nilai.startsWith("'") && nilai.endsWith("'"))) {
nilai = nilai.slice(1, -1);
}
simpul.objek[kunci] = nilai;
}
return { data: akar, masalah };
}
// ---------------------------------------------------------------------
// Bagian CLI
//
// Berkas ini juga diimpor oleh periksa-deploy.mjs, jadi blok CLI hanya
// boleh berjalan bila berkas ini dieksekusi langsung. Perbandingannya
// memakai path yang sudah di-resolve, bukan nama berkas, supaya import
// dari folder lain tidak salah dianggap sebagai eksekusi langsung.
// ---------------------------------------------------------------------
const dijalankanLangsung =
process.argv[1] !== undefined &&
import.meta.url === pathToFileURL(process.argv[1]).href;
if (dijalankanLangsung) {
const berkas = process.argv.slice(2).filter((a) => !a.startsWith('--'));
const daftar = berkas.length ? berkas : ['docker-compose.yml', 'cloudbuild.yaml'];
let gagal = false;
for (const nama of daftar) {
if (!existsSync(nama)) {
console.error(`GAGAL ${nama}: berkas tidak ditemukan`);
gagal = true;
continue;
}
try {
const { data, masalah } = parseYaml(readFileSync(nama, 'utf8'));
console.log(`OK ${nama} -> ${Object.keys(data).join(', ')}`);
if (masalah.length) {
for (const m of masalah) console.error(` ${m}`);
gagal = true;
}
if (nama === 'docker-compose.yml') {
const layanan = Object.keys(data.services ?? {});
console.log(` service: ${layanan.join(', ')}`);
if (layanan.length !== 3) {
console.error(` HARUS 3 service, ditemukan ${layanan.length}`);
gagal = true;
}
// Cek tambahan: variabel yang dipakai harus ada di .env.example.
if (existsSync('.env.example')) {
const envContoh = readFileSync('.env.example', 'utf8');
const dipakai = [...readFileSync(nama, 'utf8').matchAll(/\$\{([A-Z_][A-Z0-9_]*)/g)]
.map((m) => m[1]);
const hilang = [...new Set(dipakai)].filter((v) => !new RegExp(`^${v}=`, 'm').test(envContoh));
if (hilang.length) {
console.error(` variabel tanpa nilai di .env.example: ${hilang.join(', ')}`);
gagal = true;
}
}
}
} catch (e) {
console.error(`GAGAL ${nama}: ${e.message}`);
gagal = true;
}
}
process.exit(gagal ? 1 : 0);
}