ken_nogi/NodeSrv/docs/superpowers/plans/2026-08-10-lightsail-cli.md
Kenichiro NOGI ce58cb4be4 初回コミット: dev配下(NodeSrv/Pleasanter等)をGitea管理下に統合
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>
2026-09-04 15:37:06 +09:00

927 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<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` を新規作成:
```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: PASS6 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<Array>` — operations配列を返す
- `deleteInstance(instanceName: string, client?): Promise<Array>`
- Task 5CLIエントリがこの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: PASS8 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<Array>`
- `listSnapshots(client?): Promise<Array>`
- `deleteSnapshot(snapshotName: string, client?): Promise<Array>`
- Task 5CLIエントリがこの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: PASS11 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<any>` — 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 <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:
```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: PASS12 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 <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 5CLIコマンド体系→Task 2,3,4,5全コマンド網羅、section 6破壊的操作→Task 5のdispatchに確認プロンプト実装せず反映、section 7エラーハンドリング→全関数でSDK例外をそのままthrowcatchなし、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の実機確認で既存ードのスペックを確認してから実際の値をユーザーに伝える運用とした