單一入口 bin/tea-sdlc.js 認四個子指令。第一個位置參數是子指令,其餘 argv 原樣 交出去——既有的 flag 解析拒絕位置參數,所以子指令必須在那之前就被取走。 轉接檔裡沒有路徑,只有一句 tea-sdlc prompt --name <指令名>。正本在哪由 PATH 上 的 tea-sdlc 自己回推:fnm 把 Node 版號寫進全域安裝路徑,寫死路徑的話升一次 Node,七個平台的轉接檔會同時指向不存在的檔案,而且不會有任何錯誤訊息。 prompt 是全專案唯一輸出非 JSON 的路徑,理由只有一個:它的輸出要餵給模型讀。 失敗仍走 envelope——成功是內容,失敗才需要結構。 status 的 ok 不兼差表達環境好壞,健康與否放在 data.healthy:呼叫端要分得出 「status 掛了」與「status 成功查到你環境有問題」。 install 只寫進偵測得到的平台;缺 git/tea 只警告不中止,因為那兩個完全不影響 轉接檔產生,硬擋等於逼使用者為了裝 plugin 先去裝 tea。uninstall 只刪帶產生標記 的檔案,使用者自己寫的同名檔案一律留著並在輸出裡交代。裝哪些指令以 prompts/ 裡 實際存在的正本為準,不是寫死的六個名字——裝出指向不存在正本的轉接檔,使用者只會 看到 PROMPT_NOT_FOUND。 流程正本的 description 前綴在抄進轉接檔之前就檢查:有三個平台關不掉自動觸發, 全靠那句話把 description 窄到不會被誤判,不能等使用者發現誤觸才知道漏了。 議題 #26 #27 #28 #29 #17 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
144 lines
5.3 KiB
JavaScript
144 lines
5.3 KiB
JavaScript
/**
|
|
* prompt 子指令:agent 讀到轉接檔之後,靠它拿到流程正本的原文。
|
|
*
|
|
* 這是全專案唯一輸出非 JSON 的路徑,所以這裡釘的第一件事就是「逐字」——
|
|
* 多一個換行、少一個空白,都會讓下游模型讀到的東西與正本不同。
|
|
*/
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { manifest, repoRoot, runBin } from './helpers/run-script.js';
|
|
import { fakePrompt, makeFakePlugin } from './helpers/fake-plugin.js';
|
|
|
|
const version = () => manifest().version;
|
|
|
|
|
|
// ── 成功路徑:原樣輸出 ─────────────────────────────────────────────
|
|
|
|
test('把流程正本逐字印出來,不包 JSON、不補字元', async (t) => {
|
|
const text = fakePrompt('sdlc-plan');
|
|
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': text } });
|
|
|
|
const { code, raw } = await plugin.run(['prompt', '--name', 'sdlc-plan']);
|
|
|
|
assert.equal(code, 0);
|
|
assert.equal(raw, text);
|
|
});
|
|
|
|
test('輸出允許多行,且不是 JSON', async (t) => {
|
|
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': fakePrompt('sdlc-plan') } });
|
|
|
|
const { raw } = await plugin.run(['prompt', '--name', 'sdlc-plan']);
|
|
|
|
assert.ok(raw.split('\n').length > 3);
|
|
assert.throws(() => JSON.parse(raw.trim()));
|
|
});
|
|
|
|
test('真正的流程正本也走得通,輸出逐字等於檔案內容', async () => {
|
|
const path = join(repoRoot, 'prompts', 'sdlc-plan.md');
|
|
|
|
const { code, raw } = await runBin(['prompt', '--name', 'sdlc-plan']);
|
|
|
|
assert.equal(code, 0);
|
|
assert.equal(raw, readFileSync(path, 'utf8'));
|
|
});
|
|
|
|
|
|
// ── 失敗路徑:仍走單行 JSON ────────────────────────────────────────
|
|
|
|
test('指令不存在時回可區分的錯誤碼,而不是印出空內容當成成功', async (t) => {
|
|
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': fakePrompt('sdlc-plan') } });
|
|
|
|
const { code, json } = await plugin.run(['prompt', '--name', 'sdlc-nope']);
|
|
|
|
assert.equal(code, 1);
|
|
assert.equal(json.ok, false);
|
|
assert.equal(json.error.code, 'PROMPT_NOT_FOUND');
|
|
assert.match(json.error.message, /sdlc-nope/);
|
|
});
|
|
|
|
test('未帶 --name 時沿用既有的 MISSING_FLAG', async (t) => {
|
|
const plugin = makeFakePlugin(t);
|
|
|
|
const { code, json } = await plugin.run(['prompt']);
|
|
|
|
assert.equal(code, 1);
|
|
assert.equal(json.error.code, 'MISSING_FLAG');
|
|
assert.match(json.error.message, /--name/);
|
|
});
|
|
|
|
test('--name 不能拿來跳出 prompts 目錄', async (t) => {
|
|
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': fakePrompt('sdlc-plan') } });
|
|
|
|
const { code, json } = await plugin.run(['prompt', '--name', '../package.json']);
|
|
|
|
assert.equal(code, 1);
|
|
assert.equal(json.error.code, 'BAD_PROMPT_NAME');
|
|
});
|
|
|
|
test('plugin 目錄不完整時回既有的 PLUGIN_LAYOUT_BROKEN', async (t) => {
|
|
const plugin = makeFakePlugin(t, { omit: ['templates'] });
|
|
|
|
const { code, json } = await plugin.run(['prompt', '--name', 'sdlc-plan']);
|
|
|
|
assert.equal(code, 1);
|
|
assert.equal(json.error.code, 'PLUGIN_LAYOUT_BROKEN');
|
|
assert.match(json.error.message, /templates/);
|
|
});
|
|
|
|
|
|
// ── 轉接檔版本比對 ─────────────────────────────────────────────────
|
|
|
|
test('版本相符時的輸出與不帶該 flag 時完全一致', async (t) => {
|
|
const text = fakePrompt('sdlc-plan');
|
|
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': text }, version: '9.9.9' });
|
|
|
|
const matched = await plugin.run(['prompt', '--name', 'sdlc-plan', '--adapter-version', '9.9.9']);
|
|
const plain = await plugin.run(['prompt', '--name', 'sdlc-plan']);
|
|
|
|
assert.equal(matched.raw, plain.raw);
|
|
assert.equal(matched.raw, text);
|
|
});
|
|
|
|
test('版本不符時第一行是重跑 install 的警告,其後逐字等於正本原文', async (t) => {
|
|
const text = fakePrompt('sdlc-plan');
|
|
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': text }, version: '9.9.9' });
|
|
|
|
const { code, raw } = await plugin.run([
|
|
'prompt', '--name', 'sdlc-plan', '--adapter-version', '0.0.1',
|
|
]);
|
|
|
|
// 正本仍然要給:過時的轉接檔不該讓流程停擺
|
|
assert.equal(code, 0);
|
|
const [first, ...rest] = raw.split('\n');
|
|
assert.match(first, /tea-sdlc install/);
|
|
assert.match(first, /0\.0\.1/);
|
|
assert.match(first, /9\.9\.9/);
|
|
assert.equal(rest.join('\n').trimStart(), text);
|
|
assert.ok(raw.endsWith(text));
|
|
});
|
|
|
|
test('未帶 --adapter-version 時不產生任何警告', async (t) => {
|
|
const text = fakePrompt('sdlc-plan');
|
|
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': text } });
|
|
|
|
const { raw } = await plugin.run(['prompt', '--name', 'sdlc-plan']);
|
|
|
|
assert.equal(raw.includes('tea-sdlc install'), false);
|
|
});
|
|
|
|
test('比對用的版本只有 manifest 一個來源,沒有另外寫死一份', async (t) => {
|
|
const plugin = makeFakePlugin(t, {
|
|
prompts: { 'sdlc-plan': fakePrompt('sdlc-plan') },
|
|
version: '7.7.7',
|
|
});
|
|
|
|
const { raw } = await plugin.run([
|
|
'prompt', '--name', 'sdlc-plan', '--adapter-version', version(),
|
|
]);
|
|
|
|
// 假根的版本被改過,帶上真 repo 的版本就該被判為不符
|
|
assert.match(raw.split('\n')[0], /7\.7\.7/);
|
|
});
|