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

32 KiB
Raw Blame History

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

  • モジュール形式: CommonJSrequire/module.exports。apps/app-portal/package.jsonの"type": "commonjs"に合わせる
  • テストランナー: node:test + node:assertjest等は追加しない
  • 追加依存はこのプランの範囲で @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.jsondependencies に以下を追加:

{
  "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と同種のパターン)にマッチして出力される。マッチしない場合は .gitignoredeploy/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-lightsailLightsailClient, 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 を新規作成:

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: PASS6 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.jsgetClient@aws-sdk/client-lightsailCreateInstancesCommand, 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 の末尾に追記:

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: PASS8 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-lightsailCreateInstanceSnapshotCommand, 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 の末尾に追記:

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: PASS11 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: PASS12 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.envgitignore済みを作成:

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 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オプション名--bundlebundleId, --blueprintblueprintId, --azavailabilityZoneの対応もTask 5内で一貫
  • 未決事項spec section 9: bundle/blueprint/azのデフォルト値は「都度指定」方式を採用CLIオプション必須、デフォルトなしとし、Task 6の実機確認で既存ードのスペックを確認してから実際の値をユーザーに伝える運用とした