GitHub(nextgroup2706/ken_nogi)は今後使わず自社Gitea運用に切替え。 NodeSrvは旧リポジトリの履歴を破棄しファイルのみ統合(Dokploy用サービスアカウントは 別途mygit-admin/NodeSrv.gitに履歴あり)。notepmエクスポート(12GB)とPleasanter インストーラzip(208MB)はサイズが大きいため.gitignoreで除外。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
32 KiB
Lightsail CLI制御基盤 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Claude CodeがCLIコマンド経由でAWS Lightsailインスタンス(一覧・作成削除・起動停止再起動・スナップショット)を直接操作できるようにする。
Architecture: apps/app-portal/src/lightsailClient.js にAWS SDK v3 (@aws-sdk/client-lightsail) の薄いラッパー関数群を実装(dokployClient.jsと同型:関数ごとに1操作、client引数はDI可能でデフォルトは本物のSDKクライアント)。apps/app-portal/scripts/lightsail-cli.js がサブコマンドをパースしてラッパー関数を呼び、結果をJSON整形出力する。
Tech Stack: Node.js (commonjs), @aws-sdk/client-lightsail, node:test(既存app-portalと統一)
Global Constraints
- モジュール形式: CommonJS(
require/module.exports)。apps/app-portal/package.jsonの"type": "commonjs"に合わせる - テストランナー:
node:test+node:assert(jest等は追加しない) - 追加依存はこのプランの範囲で
@aws-sdk/client-lightsailのみ - AWSリージョンは
ap-northeast-1固定(deploy/lightsail.envで設定、コード内にハードコードしない) - 破壊的操作(delete系)はCLI内部で確認プロンプトを実装しない。呼び出し側(Claude Code運用)が確認する前提
- ファイル配置は spec (
docs/superpowers/specs/2026-08-10-lightsail-cli-design.md) の構成に厳密に従う
Task 1: 依存追加と環境変数テンプレート
Files:
- Modify:
apps/app-portal/package.json - Create:
deploy/lightsail.env.example
Interfaces:
-
Produces:
@aws-sdk/client-lightsailパッケージがapps/app-portal/node_modulesにインストール済み。以降のタスクがこれをrequire('@aws-sdk/client-lightsail')で使う -
Step 1: package.jsonに依存追加
apps/app-portal/package.json の dependencies に以下を追加:
{
"name": "app-portal",
"version": "0.1.0",
"private": true,
"type": "commonjs",
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"test": "node --test"
},
"dependencies": {
"@aws-sdk/client-lightsail": "^3.0.0",
"express": "^4.21.0"
}
}
- Step 2: インストール実行
Run:
cd apps/app-portal && npm install
Expected: node_modules/@aws-sdk/client-lightsail が生成され、package-lock.json が更新される。エラーなく終了すること。
- Step 3: 環境変数テンプレート作成
deploy/lightsail.env.example を新規作成:
# AWS Lightsail API接続情報。
# 専用IAMユーザー(AmazonLightsailFullAccessポリシー)のアクセスキーを使用する。
# 実値は `deploy/lightsail.env`(gitignore済み・コミットしない)に記入する。
# IAMユーザーのアクセスキーID
AWS_ACCESS_KEY_ID=
# IAMユーザーのシークレットアクセスキー
AWS_SECRET_ACCESS_KEY=
# AWSリージョン(既存Dokploy稼働Lightsailと同一リージョン)
AWS_REGION=ap-northeast-1
- Step 4: .gitignoreに
deploy/lightsail.envが含まれているか確認
Run:
git check-ignore -v deploy/lightsail.env
Expected: .gitignore内の既存ルール(deploy/dokploy.envと同種のパターン)にマッチして出力される。マッチしない場合は .gitignore に deploy/lightsail.env を追記する。
- Step 5: コミット
git add apps/app-portal/package.json apps/app-portal/package-lock.json deploy/lightsail.env.example
git commit -m "feat(app-portal): Lightsail API用の依存・env追加"
Task 2: 基本操作(一覧・状態取得・起動停止再起動)
Files:
- Create:
apps/app-portal/src/lightsailClient.js - Create:
apps/app-portal/test/lightsailClient.test.js
Interfaces:
-
Consumes:
@aws-sdk/client-lightsailのLightsailClient,GetInstancesCommand,GetInstanceCommand,StartInstanceCommand,StopInstanceCommand,RebootInstanceCommand -
Produces:
getConfig(): { region, accessKeyId, secretAccessKey }— env未設定ならthrowgetClient(): LightsailClientlistInstances(client?): Promise<Array>—client省略時はgetClient()getInstance(instanceName: string, client?): Promise<Object>startInstance(instanceName: string, client?): Promise<Array>— operations配列を返すstopInstance(instanceName: string, client?): Promise<Array>rebootInstance(instanceName: string, client?): Promise<Array>- これら全て
module.exportsから取得可能。以降のタスク(Task 3, 4, 5)がこのモジュールに関数を追加していく
-
Step 1: 失敗するテストを書く
apps/app-portal/test/lightsailClient.test.js を新規作成:
const { test } = require('node:test');
const assert = require('node:assert');
function mockClient(sendImpl) {
return { send: sendImpl };
}
test('getConfig throws when AWS env vars are missing', () => {
delete process.env.AWS_REGION;
delete process.env.AWS_ACCESS_KEY_ID;
delete process.env.AWS_SECRET_ACCESS_KEY;
const { getConfig } = require('../src/lightsailClient');
assert.throws(() => getConfig(), /AWS_REGION/);
});
test('listInstances sends GetInstancesCommand and returns instances', async () => {
const { listInstances } = require('../src/lightsailClient');
const calls = [];
const client = mockClient(async (cmd) => {
calls.push(cmd);
return { instances: [{ name: 'xwiki-01' }] };
});
const result = await listInstances(client);
assert.deepStrictEqual(result, [{ name: 'xwiki-01' }]);
assert.strictEqual(calls[0].constructor.name, 'GetInstancesCommand');
});
test('getInstance sends GetInstanceCommand with instanceName and returns instance', async () => {
const { getInstance } = require('../src/lightsailClient');
const calls = [];
const client = mockClient(async (cmd) => {
calls.push(cmd);
return { instance: { name: 'xwiki-01', state: { name: 'running' } } };
});
const result = await getInstance('xwiki-01', client);
assert.deepStrictEqual(result, { name: 'xwiki-01', state: { name: 'running' } });
assert.strictEqual(calls[0].constructor.name, 'GetInstanceCommand');
assert.strictEqual(calls[0].input.instanceName, 'xwiki-01');
});
test('startInstance sends StartInstanceCommand with instanceName and returns operations', async () => {
const { startInstance } = require('../src/lightsailClient');
const calls = [];
const client = mockClient(async (cmd) => {
calls.push(cmd);
return { operations: [{ status: 'Started' }] };
});
const result = await startInstance('xwiki-01', client);
assert.deepStrictEqual(result, [{ status: 'Started' }]);
assert.strictEqual(calls[0].constructor.name, 'StartInstanceCommand');
assert.strictEqual(calls[0].input.instanceName, 'xwiki-01');
});
test('stopInstance sends StopInstanceCommand with instanceName', async () => {
const { stopInstance } = require('../src/lightsailClient');
const calls = [];
const client = mockClient(async (cmd) => {
calls.push(cmd);
return { operations: [{ status: 'Stopped' }] };
});
const result = await stopInstance('xwiki-01', client);
assert.deepStrictEqual(result, [{ status: 'Stopped' }]);
assert.strictEqual(calls[0].constructor.name, 'StopInstanceCommand');
assert.strictEqual(calls[0].input.instanceName, 'xwiki-01');
});
test('rebootInstance sends RebootInstanceCommand with instanceName', async () => {
const { rebootInstance } = require('../src/lightsailClient');
const calls = [];
const client = mockClient(async (cmd) => {
calls.push(cmd);
return { operations: [{ status: 'Rebooting' }] };
});
const result = await rebootInstance('xwiki-01', client);
assert.deepStrictEqual(result, [{ status: 'Rebooting' }]);
assert.strictEqual(calls[0].constructor.name, 'RebootInstanceCommand');
assert.strictEqual(calls[0].input.instanceName, 'xwiki-01');
});
- Step 2: テスト実行して失敗を確認
Run:
cd apps/app-portal && node --test test/lightsailClient.test.js
Expected: FAIL — Cannot find module '../src/lightsailClient'
- Step 3: 最小実装
apps/app-portal/src/lightsailClient.js を新規作成:
const {
LightsailClient,
GetInstancesCommand,
GetInstanceCommand,
StartInstanceCommand,
StopInstanceCommand,
RebootInstanceCommand,
} = require('@aws-sdk/client-lightsail');
function getConfig() {
const region = process.env.AWS_REGION;
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
if (!region || !accessKeyId || !secretAccessKey) {
throw new Error('AWS_REGION / AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY が設定されていません');
}
return { region, accessKeyId, secretAccessKey };
}
function getClient() {
const { region, accessKeyId, secretAccessKey } = getConfig();
return new LightsailClient({ region, credentials: { accessKeyId, secretAccessKey } });
}
async function listInstances(client = getClient()) {
const res = await client.send(new GetInstancesCommand({}));
return res.instances;
}
async function getInstance(instanceName, client = getClient()) {
const res = await client.send(new GetInstanceCommand({ instanceName }));
return res.instance;
}
async function startInstance(instanceName, client = getClient()) {
const res = await client.send(new StartInstanceCommand({ instanceName }));
return res.operations;
}
async function stopInstance(instanceName, client = getClient()) {
const res = await client.send(new StopInstanceCommand({ instanceName }));
return res.operations;
}
async function rebootInstance(instanceName, client = getClient()) {
const res = await client.send(new RebootInstanceCommand({ instanceName }));
return res.operations;
}
module.exports = {
getConfig,
getClient,
listInstances,
getInstance,
startInstance,
stopInstance,
rebootInstance,
};
- Step 4: テスト実行して成功を確認
Run:
cd apps/app-portal && node --test test/lightsailClient.test.js
Expected: PASS(6 tests)
- Step 5: コミット
git add apps/app-portal/src/lightsailClient.js apps/app-portal/test/lightsailClient.test.js
git commit -m "feat(app-portal): lightsailClient基本操作(一覧・状態・起動停止再起動)"
Task 3: インスタンス作成・削除
Files:
- Modify:
apps/app-portal/src/lightsailClient.js - Modify:
apps/app-portal/test/lightsailClient.test.js
Interfaces:
-
Consumes: Task 2で作成した
lightsailClient.jsのgetClient。@aws-sdk/client-lightsailのCreateInstancesCommand,DeleteInstanceCommand -
Produces:
createInstance({ instanceName, availabilityZone, blueprintId, bundleId }, client?): Promise<Array>— operations配列を返すdeleteInstance(instanceName: string, client?): Promise<Array>- Task 5(CLIエントリ)がこの2関数を呼ぶ
-
Step 1: 失敗するテストを追加
apps/app-portal/test/lightsailClient.test.js の末尾に追記:
test('createInstance sends CreateInstancesCommand with correct params', async () => {
const { createInstance } = require('../src/lightsailClient');
const calls = [];
const client = mockClient(async (cmd) => {
calls.push(cmd);
return { operations: [{ status: 'Started', operationType: 'CreateInstance' }] };
});
const result = await createInstance({
instanceName: 'xwiki-02',
availabilityZone: 'ap-northeast-1a',
blueprintId: 'ubuntu_22_04',
bundleId: 'small_2_0',
}, client);
assert.deepStrictEqual(result, [{ status: 'Started', operationType: 'CreateInstance' }]);
assert.strictEqual(calls[0].constructor.name, 'CreateInstancesCommand');
assert.deepStrictEqual(calls[0].input.instanceNames, ['xwiki-02']);
assert.strictEqual(calls[0].input.availabilityZone, 'ap-northeast-1a');
assert.strictEqual(calls[0].input.blueprintId, 'ubuntu_22_04');
assert.strictEqual(calls[0].input.bundleId, 'small_2_0');
});
test('deleteInstance sends DeleteInstanceCommand with instanceName', async () => {
const { deleteInstance } = require('../src/lightsailClient');
const calls = [];
const client = mockClient(async (cmd) => {
calls.push(cmd);
return { operations: [{ status: 'Started', operationType: 'DeleteInstance' }] };
});
const result = await deleteInstance('xwiki-02', client);
assert.deepStrictEqual(result, [{ status: 'Started', operationType: 'DeleteInstance' }]);
assert.strictEqual(calls[0].constructor.name, 'DeleteInstanceCommand');
assert.strictEqual(calls[0].input.instanceName, 'xwiki-02');
});
- Step 2: テスト実行して失敗を確認
Run:
cd apps/app-portal && node --test test/lightsailClient.test.js
Expected: FAIL — createInstance is not a function
- Step 3: 実装追加
apps/app-portal/src/lightsailClient.js の先頭importを変更:
const {
LightsailClient,
GetInstancesCommand,
GetInstanceCommand,
StartInstanceCommand,
StopInstanceCommand,
RebootInstanceCommand,
CreateInstancesCommand,
DeleteInstanceCommand,
} = require('@aws-sdk/client-lightsail');
module.exports の直前に関数を追加:
async function createInstance({ instanceName, availabilityZone, blueprintId, bundleId }, client = getClient()) {
const res = await client.send(new CreateInstancesCommand({
instanceNames: [instanceName],
availabilityZone,
blueprintId,
bundleId,
}));
return res.operations;
}
async function deleteInstance(instanceName, client = getClient()) {
const res = await client.send(new DeleteInstanceCommand({ instanceName }));
return res.operations;
}
module.exports に追加:
module.exports = {
getConfig,
getClient,
listInstances,
getInstance,
startInstance,
stopInstance,
rebootInstance,
createInstance,
deleteInstance,
};
- Step 4: テスト実行して成功を確認
Run:
cd apps/app-portal && node --test test/lightsailClient.test.js
Expected: PASS(8 tests)
- Step 5: コミット
git add apps/app-portal/src/lightsailClient.js apps/app-portal/test/lightsailClient.test.js
git commit -m "feat(app-portal): lightsailClientにインスタンス作成・削除を追加"
Task 4: スナップショット操作
Files:
- Modify:
apps/app-portal/src/lightsailClient.js - Modify:
apps/app-portal/test/lightsailClient.test.js
Interfaces:
-
Consumes:
@aws-sdk/client-lightsailのCreateInstanceSnapshotCommand,GetInstanceSnapshotsCommand,DeleteInstanceSnapshotCommand -
Produces:
createSnapshot(instanceName: string, snapshotName: string, client?): Promise<Array>listSnapshots(client?): Promise<Array>deleteSnapshot(snapshotName: string, client?): Promise<Array>- Task 5(CLIエントリ)がこの3関数を呼ぶ
-
Step 1: 失敗するテストを追加
apps/app-portal/test/lightsailClient.test.js の末尾に追記:
test('createSnapshot sends CreateInstanceSnapshotCommand with instanceName and snapshotName', async () => {
const { createSnapshot } = require('../src/lightsailClient');
const calls = [];
const client = mockClient(async (cmd) => {
calls.push(cmd);
return { operations: [{ status: 'Started', operationType: 'CreateInstanceSnapshot' }] };
});
const result = await createSnapshot('xwiki-01', 'xwiki-01-backup-20260810', client);
assert.deepStrictEqual(result, [{ status: 'Started', operationType: 'CreateInstanceSnapshot' }]);
assert.strictEqual(calls[0].constructor.name, 'CreateInstanceSnapshotCommand');
assert.strictEqual(calls[0].input.instanceName, 'xwiki-01');
assert.strictEqual(calls[0].input.instanceSnapshotName, 'xwiki-01-backup-20260810');
});
test('listSnapshots sends GetInstanceSnapshotsCommand and returns instanceSnapshots', async () => {
const { listSnapshots } = require('../src/lightsailClient');
const calls = [];
const client = mockClient(async (cmd) => {
calls.push(cmd);
return { instanceSnapshots: [{ name: 'xwiki-01-backup-20260810' }] };
});
const result = await listSnapshots(client);
assert.deepStrictEqual(result, [{ name: 'xwiki-01-backup-20260810' }]);
assert.strictEqual(calls[0].constructor.name, 'GetInstanceSnapshotsCommand');
});
test('deleteSnapshot sends DeleteInstanceSnapshotCommand with instanceSnapshotName', async () => {
const { deleteSnapshot } = require('../src/lightsailClient');
const calls = [];
const client = mockClient(async (cmd) => {
calls.push(cmd);
return { operations: [{ status: 'Started', operationType: 'DeleteInstanceSnapshot' }] };
});
const result = await deleteSnapshot('xwiki-01-backup-20260810', client);
assert.deepStrictEqual(result, [{ status: 'Started', operationType: 'DeleteInstanceSnapshot' }]);
assert.strictEqual(calls[0].constructor.name, 'DeleteInstanceSnapshotCommand');
assert.strictEqual(calls[0].input.instanceSnapshotName, 'xwiki-01-backup-20260810');
});
- Step 2: テスト実行して失敗を確認
Run:
cd apps/app-portal && node --test test/lightsailClient.test.js
Expected: FAIL — createSnapshot is not a function
- Step 3: 実装追加
apps/app-portal/src/lightsailClient.js の先頭importを変更:
const {
LightsailClient,
GetInstancesCommand,
GetInstanceCommand,
StartInstanceCommand,
StopInstanceCommand,
RebootInstanceCommand,
CreateInstancesCommand,
DeleteInstanceCommand,
CreateInstanceSnapshotCommand,
GetInstanceSnapshotsCommand,
DeleteInstanceSnapshotCommand,
} = require('@aws-sdk/client-lightsail');
module.exports の直前に関数を追加:
async function createSnapshot(instanceName, snapshotName, client = getClient()) {
const res = await client.send(new CreateInstanceSnapshotCommand({
instanceSnapshotName: snapshotName,
instanceName,
}));
return res.operations;
}
async function listSnapshots(client = getClient()) {
const res = await client.send(new GetInstanceSnapshotsCommand({}));
return res.instanceSnapshots;
}
async function deleteSnapshot(snapshotName, client = getClient()) {
const res = await client.send(new DeleteInstanceSnapshotCommand({ instanceSnapshotName: snapshotName }));
return res.operations;
}
module.exports に追加:
module.exports = {
getConfig,
getClient,
listInstances,
getInstance,
startInstance,
stopInstance,
rebootInstance,
createInstance,
deleteInstance,
createSnapshot,
listSnapshots,
deleteSnapshot,
};
- Step 4: テスト実行して成功を確認
Run:
cd apps/app-portal && node --test test/lightsailClient.test.js
Expected: PASS(11 tests)
- Step 5: コミット
git add apps/app-portal/src/lightsailClient.js apps/app-portal/test/lightsailClient.test.js
git commit -m "feat(app-portal): lightsailClientにスナップショット操作を追加"
Task 5: CLIエントリスクリプト
Files:
- Create:
apps/app-portal/scripts/lightsail-cli.js - Create:
apps/app-portal/test/lightsail-cli.test.js
Interfaces:
-
Consumes: Task 2〜4で実装した
lightsailClient.jsの全関数(listInstances,getInstance,startInstance,stopInstance,rebootInstance,createInstance,deleteInstance,createSnapshot,listSnapshots,deleteSnapshot) -
Produces:
parseArgs(argv: string[]): { command: string, subcommand: string|null, args: string[], options: Object }— CLIエントリ内部のパーサー。テストから直接呼べるようexportする -
Produces:
dispatch(parsed, clientModule): Promise<any>— parseArgsの結果とlightsailClientモジュール(DI用)を受け取り、対応する関数を呼んで結果を返す。テストではモック版clientModuleを注入する -
Step 1: 失敗するテストを書く
apps/app-portal/test/lightsail-cli.test.js を新規作成:
const { test } = require('node:test');
const assert = require('node:assert');
const { parseArgs, dispatch } = require('../scripts/lightsail-cli');
test('parseArgs parses "list" with no args', () => {
const result = parseArgs(['list']);
assert.deepStrictEqual(result, { command: 'list', subcommand: null, args: [], options: {} });
});
test('parseArgs parses "status <name>"', () => {
const result = parseArgs(['status', 'xwiki-01']);
assert.deepStrictEqual(result, { command: 'status', subcommand: null, args: ['xwiki-01'], options: {} });
});
test('parseArgs parses "create <name> --bundle <id> --blueprint <id> --az <zone>"', () => {
const result = parseArgs(['create', 'xwiki-02', '--bundle', 'small_2_0', '--blueprint', 'ubuntu_22_04', '--az', 'ap-northeast-1a']);
assert.deepStrictEqual(result, {
command: 'create',
subcommand: null,
args: ['xwiki-02'],
options: { bundle: 'small_2_0', blueprint: 'ubuntu_22_04', az: 'ap-northeast-1a' },
});
});
test('parseArgs parses "snapshot create <instance> <name>" as subcommand', () => {
const result = parseArgs(['snapshot', 'create', 'xwiki-01', 'xwiki-01-backup']);
assert.deepStrictEqual(result, {
command: 'snapshot',
subcommand: 'create',
args: ['xwiki-01', 'xwiki-01-backup'],
options: {},
});
});
test('dispatch("list") calls clientModule.listInstances', async () => {
const calls = [];
const clientModule = { listInstances: async () => { calls.push('listInstances'); return [{ name: 'xwiki-01' }]; } };
const result = await dispatch({ command: 'list', subcommand: null, args: [], options: {} }, clientModule);
assert.deepStrictEqual(result, [{ name: 'xwiki-01' }]);
assert.deepStrictEqual(calls, ['listInstances']);
});
test('dispatch("status", ["xwiki-01"]) calls clientModule.getInstance with name', async () => {
const calls = [];
const clientModule = { getInstance: async (name) => { calls.push(name); return { name: 'xwiki-01', state: { name: 'running' } }; } };
const result = await dispatch({ command: 'status', subcommand: null, args: ['xwiki-01'], options: {} }, clientModule);
assert.deepStrictEqual(result, { name: 'xwiki-01', state: { name: 'running' } });
assert.deepStrictEqual(calls, ['xwiki-01']);
});
test('dispatch("create", ...) calls clientModule.createInstance with parsed options', async () => {
const calls = [];
const clientModule = { createInstance: async (opts) => { calls.push(opts); return [{ status: 'Started' }]; } };
const parsed = {
command: 'create',
subcommand: null,
args: ['xwiki-02'],
options: { bundle: 'small_2_0', blueprint: 'ubuntu_22_04', az: 'ap-northeast-1a' },
};
const result = await dispatch(parsed, clientModule);
assert.deepStrictEqual(result, [{ status: 'Started' }]);
assert.deepStrictEqual(calls, [{
instanceName: 'xwiki-02',
bundleId: 'small_2_0',
blueprintId: 'ubuntu_22_04',
availabilityZone: 'ap-northeast-1a',
}]);
});
test('dispatch("delete", ["xwiki-02"]) calls clientModule.deleteInstance', async () => {
const calls = [];
const clientModule = { deleteInstance: async (name) => { calls.push(name); return [{ status: 'Started' }]; } };
const result = await dispatch({ command: 'delete', subcommand: null, args: ['xwiki-02'], options: {} }, clientModule);
assert.deepStrictEqual(result, [{ status: 'Started' }]);
assert.deepStrictEqual(calls, ['xwiki-02']);
});
test('dispatch("start"/"stop"/"reboot") call the matching clientModule function', async () => {
const calls = [];
const clientModule = {
startInstance: async (name) => { calls.push(['start', name]); return [{ status: 'Started' }]; },
stopInstance: async (name) => { calls.push(['stop', name]); return [{ status: 'Started' }]; },
rebootInstance: async (name) => { calls.push(['reboot', name]); return [{ status: 'Started' }]; },
};
await dispatch({ command: 'start', subcommand: null, args: ['xwiki-01'], options: {} }, clientModule);
await dispatch({ command: 'stop', subcommand: null, args: ['xwiki-01'], options: {} }, clientModule);
await dispatch({ command: 'reboot', subcommand: null, args: ['xwiki-01'], options: {} }, clientModule);
assert.deepStrictEqual(calls, [['start', 'xwiki-01'], ['stop', 'xwiki-01'], ['reboot', 'xwiki-01']]);
});
test('dispatch("snapshot", "create", [...]) calls clientModule.createSnapshot', async () => {
const calls = [];
const clientModule = { createSnapshot: async (instanceName, snapshotName) => { calls.push([instanceName, snapshotName]); return [{ status: 'Started' }]; } };
const parsed = { command: 'snapshot', subcommand: 'create', args: ['xwiki-01', 'xwiki-01-backup'], options: {} };
const result = await dispatch(parsed, clientModule);
assert.deepStrictEqual(result, [{ status: 'Started' }]);
assert.deepStrictEqual(calls, [['xwiki-01', 'xwiki-01-backup']]);
});
test('dispatch("snapshot", "list", []) calls clientModule.listSnapshots', async () => {
const clientModule = { listSnapshots: async () => [{ name: 'xwiki-01-backup' }] };
const parsed = { command: 'snapshot', subcommand: 'list', args: [], options: {} };
const result = await dispatch(parsed, clientModule);
assert.deepStrictEqual(result, [{ name: 'xwiki-01-backup' }]);
});
test('dispatch("snapshot", "delete", [name]) calls clientModule.deleteSnapshot', async () => {
const calls = [];
const clientModule = { deleteSnapshot: async (name) => { calls.push(name); return [{ status: 'Started' }]; } };
const parsed = { command: 'snapshot', subcommand: 'delete', args: ['xwiki-01-backup'], options: {} };
const result = await dispatch(parsed, clientModule);
assert.deepStrictEqual(result, [{ status: 'Started' }]);
assert.deepStrictEqual(calls, ['xwiki-01-backup']);
});
test('dispatch throws on unknown command', async () => {
await assert.rejects(
() => dispatch({ command: 'bogus', subcommand: null, args: [], options: {} }, {}),
/Unknown command: bogus/
);
});
- Step 2: テスト実行して失敗を確認
Run:
cd apps/app-portal && node --test test/lightsail-cli.test.js
Expected: FAIL — Cannot find module '../scripts/lightsail-cli'
- Step 3: 実装
apps/app-portal/scripts/lightsail-cli.js を新規作成:
const lightsailClient = require('../src/lightsailClient');
function parseArgs(argv) {
const SUBCOMMANDED = new Set(['snapshot']);
const [command, ...rest] = argv;
if (SUBCOMMANDED.has(command)) {
const [subcommand, ...subRest] = rest;
return { command, subcommand, args: subRest, options: {} };
}
const args = [];
const options = {};
for (let i = 0; i < rest.length; i += 1) {
const token = rest[i];
if (token.startsWith('--')) {
const key = token.slice(2);
options[key] = rest[i + 1];
i += 1;
} else {
args.push(token);
}
}
return { command, subcommand: null, args, options };
}
async function dispatch(parsed, clientModule) {
const { command, subcommand, args, options } = parsed;
if (command === 'list') {
return clientModule.listInstances();
}
if (command === 'status') {
return clientModule.getInstance(args[0]);
}
if (command === 'create') {
return clientModule.createInstance({
instanceName: args[0],
bundleId: options.bundle,
blueprintId: options.blueprint,
availabilityZone: options.az,
});
}
if (command === 'delete') {
return clientModule.deleteInstance(args[0]);
}
if (command === 'start') {
return clientModule.startInstance(args[0]);
}
if (command === 'stop') {
return clientModule.stopInstance(args[0]);
}
if (command === 'reboot') {
return clientModule.rebootInstance(args[0]);
}
if (command === 'snapshot' && subcommand === 'create') {
return clientModule.createSnapshot(args[0], args[1]);
}
if (command === 'snapshot' && subcommand === 'list') {
return clientModule.listSnapshots();
}
if (command === 'snapshot' && subcommand === 'delete') {
return clientModule.deleteSnapshot(args[0]);
}
throw new Error(`Unknown command: ${command}`);
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
const result = await dispatch(parsed, lightsailClient);
console.log(JSON.stringify(result, null, 2));
}
if (require.main === module) {
main().catch((err) => {
console.error(err.message);
process.exit(1);
});
}
module.exports = { parseArgs, dispatch };
- Step 4: テスト実行して成功を確認
Run:
cd apps/app-portal && node --test test/lightsail-cli.test.js
Expected: PASS(12 tests)
- Step 5: 全テストスイート実行
Run:
cd apps/app-portal && npm test
Expected: PASS(既存テスト含め全件)
- Step 6: コミット
git add apps/app-portal/scripts/lightsail-cli.js apps/app-portal/test/lightsail-cli.test.js
git commit -m "feat(app-portal): Lightsail CLIエントリスクリプト追加"
Task 6: 実機動作確認
Files: なし(動作確認のみ、コード変更なし)
Interfaces:
-
Consumes: Task 1〜5で実装した全機能
-
Step 1: IAMユーザーのアクセスキーを
deploy/lightsail.envに設定
ユーザー側でAWSコンソールから発行したアクセスキーを使い、deploy/lightsail.env(gitignore済み)を作成:
AWS_ACCESS_KEY_ID=<実際の値>
AWS_SECRET_ACCESS_KEY=<実際の値>
AWS_REGION=ap-northeast-1
- Step 2: env読み込みつつlistコマンドを実行
Run:
cd apps/app-portal && node -r dotenv/config scripts/lightsail-cli.js list dotenv_config_path=../../deploy/lightsail.env
dotenvパッケージが無い場合は、環境変数を直接設定して実行:
Windows PowerShellの場合:
Get-Content ../../deploy/lightsail.env | ForEach-Object { if ($_ -match '^([^#=]+)=(.*)$') { [Environment]::SetEnvironmentVariable($matches[1], $matches[2]) } }
node scripts/lightsail-cli.js list
Expected: 既存Dokploy稼働中のLightsailインスタンスがJSON配列で出力される(エラーなし、AWS APIとの疎通確認)。
- Step 3: statusコマンドで既存インスタンスの詳細確認
Run:
node scripts/lightsail-cli.js status <Step2で確認した実際のインスタンス名>
Expected: インスタンスのbundleId, state, publicIpAddress等を含むJSONが出力される。
- Step 4: 結果をユーザーに報告
create/delete/snapshot系は既存本番環境への影響があるため、このタスクでは実行しない。list/statusの疎通確認のみで完了とする。
Self-Review Notes
- Spec coverage: spec section 3(アーキテクチャ)→Task 1,5、section 4(認証情報)→Task 1,6、section 5(CLIコマンド体系)→Task 2,3,4,5全コマンド網羅、section 6(破壊的操作)→Task 5のdispatchに確認プロンプト実装せず反映、section 7(エラーハンドリング)→全関数でSDK例外をそのままthrow(catchなし)、section 8(テスト方針)→DI可能な
client引数+モック、全タスクで踏襲 - 型一貫性:
createInstanceの引数オブジェクトキー(instanceName,availabilityZone,blueprintId,bundleId)はTask 3実装とTask 5 dispatch呼び出しで一致確認済み。CLIオプション名(--bundle→bundleId,--blueprint→blueprintId,--az→availabilityZone)の対応もTask 5内で一貫 - 未決事項(spec section 9): bundle/blueprint/azのデフォルト値は「都度指定」方式を採用(CLIオプション必須、デフォルトなし)とし、Task 6の実機確認で既存ノードのスペックを確認してから実際の値をユーザーに伝える運用とした