# 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` に以下を追加: ```json { "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: ```bash 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: ```bash git check-ignore -v deploy/lightsail.env ``` Expected: `.gitignore`内の既存ルール(`deploy/dokploy.env`と同種のパターン)にマッチして出力される。マッチしない場合は `.gitignore` に `deploy/lightsail.env` を追記する。 - [ ] **Step 5: コミット** ```bash 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未設定ならthrow - `getClient(): LightsailClient` - `listInstances(client?): Promise` — `client`省略時は`getClient()` - `getInstance(instanceName: string, client?): Promise` - `startInstance(instanceName: string, client?): Promise` — operations配列を返す - `stopInstance(instanceName: string, client?): Promise` - `rebootInstance(instanceName: string, client?): Promise` - これら全て `module.exports` から取得可能。以降のタスク(Task 3, 4, 5)がこのモジュールに関数を追加していく - [ ] **Step 1: 失敗するテストを書く** `apps/app-portal/test/lightsailClient.test.js` を新規作成: ```javascript 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: ```bash 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` を新規作成: ```javascript 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: ```bash cd apps/app-portal && node --test test/lightsailClient.test.js ``` Expected: PASS(6 tests) - [ ] **Step 5: コミット** ```bash 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` — operations配列を返す - `deleteInstance(instanceName: string, client?): Promise` - Task 5(CLIエントリ)がこの2関数を呼ぶ - [ ] **Step 1: 失敗するテストを追加** `apps/app-portal/test/lightsailClient.test.js` の末尾に追記: ```javascript 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: ```bash 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を変更: ```javascript const { LightsailClient, GetInstancesCommand, GetInstanceCommand, StartInstanceCommand, StopInstanceCommand, RebootInstanceCommand, CreateInstancesCommand, DeleteInstanceCommand, } = require('@aws-sdk/client-lightsail'); ``` `module.exports` の直前に関数を追加: ```javascript 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` に追加: ```javascript module.exports = { getConfig, getClient, listInstances, getInstance, startInstance, stopInstance, rebootInstance, createInstance, deleteInstance, }; ``` - [ ] **Step 4: テスト実行して成功を確認** Run: ```bash cd apps/app-portal && node --test test/lightsailClient.test.js ``` Expected: PASS(8 tests) - [ ] **Step 5: コミット** ```bash 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` - `listSnapshots(client?): Promise` - `deleteSnapshot(snapshotName: string, client?): Promise` - Task 5(CLIエントリ)がこの3関数を呼ぶ - [ ] **Step 1: 失敗するテストを追加** `apps/app-portal/test/lightsailClient.test.js` の末尾に追記: ```javascript 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: ```bash 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を変更: ```javascript const { LightsailClient, GetInstancesCommand, GetInstanceCommand, StartInstanceCommand, StopInstanceCommand, RebootInstanceCommand, CreateInstancesCommand, DeleteInstanceCommand, CreateInstanceSnapshotCommand, GetInstanceSnapshotsCommand, DeleteInstanceSnapshotCommand, } = require('@aws-sdk/client-lightsail'); ``` `module.exports` の直前に関数を追加: ```javascript 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` に追加: ```javascript module.exports = { getConfig, getClient, listInstances, getInstance, startInstance, stopInstance, rebootInstance, createInstance, deleteInstance, createSnapshot, listSnapshots, deleteSnapshot, }; ``` - [ ] **Step 4: テスト実行して成功を確認** Run: ```bash cd apps/app-portal && node --test test/lightsailClient.test.js ``` Expected: PASS(11 tests) - [ ] **Step 5: コミット** ```bash 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` — parseArgsの結果とlightsailClientモジュール(DI用)を受け取り、対応する関数を呼んで結果を返す。テストではモック版`clientModule`を注入する - [ ] **Step 1: 失敗するテストを書く** `apps/app-portal/test/lightsail-cli.test.js` を新規作成: ```javascript 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 "', () => { const result = parseArgs(['status', 'xwiki-01']); assert.deepStrictEqual(result, { command: 'status', subcommand: null, args: ['xwiki-01'], options: {} }); }); test('parseArgs parses "create --bundle --blueprint --az "', () => { 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 " 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: ```bash 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` を新規作成: ```javascript 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: ```bash cd apps/app-portal && node --test test/lightsail-cli.test.js ``` Expected: PASS(12 tests) - [ ] **Step 5: 全テストスイート実行** Run: ```bash cd apps/app-portal && npm test ``` Expected: PASS(既存テスト含め全件) - [ ] **Step 6: コミット** ```bash 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: ```bash cd apps/app-portal && node -r dotenv/config scripts/lightsail-cli.js list dotenv_config_path=../../deploy/lightsail.env ``` `dotenv`パッケージが無い場合は、環境変数を直接設定して実行: Windows PowerShellの場合: ```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: ```bash node scripts/lightsail-cli.js status ``` 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の実機確認で既存ノードのスペックを確認してから実際の値をユーザーに伝える運用とした