gameplatform-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -0
- package/bin/gameplatform.js +118 -0
- package/package.json +39 -0
- package/src/api.js +24 -0
- package/src/auth.js +79 -0
- package/src/checklist.js +15 -0
- package/src/commands/connect.js +81 -0
- package/src/commands/delete.js +23 -0
- package/src/commands/deploy.js +103 -0
- package/src/commands/games.js +21 -0
- package/src/commands/login.js +191 -0
- package/src/commands/logout.js +6 -0
- package/src/commands/pricing.js +48 -0
- package/src/commands/promote.js +43 -0
- package/src/commands/screenshot.js +61 -0
- package/src/commands/status.js +22 -0
- package/src/commands/thumbnail.js +60 -0
- package/src/commands/update.js +71 -0
- package/src/commands/video.js +61 -0
- package/src/config.js +9 -0
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# gameplatform-cli
|
|
2
|
+
|
|
3
|
+
Publish and monetize browser games on **GamePlatform** from your terminal.
|
|
4
|
+
Designed to be driven by an AI assistant (Claude Code / Codex / Cursor): hand it
|
|
5
|
+
the [onboarding runbook](https://game-platform-3eg.pages.dev/creator-onboarding.md)
|
|
6
|
+
and it can do almost everything. The only human step is Stripe identity/bank
|
|
7
|
+
verification (KYC).
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
npm install -g gameplatform-cli
|
|
13
|
+
gameplatform --help
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Requires Node 18+.
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# 1. Log in (one time)
|
|
22
|
+
gameplatform login # email/password
|
|
23
|
+
gameplatform login --browser # or Google
|
|
24
|
+
|
|
25
|
+
# 2. Set up payouts — Stripe Connect
|
|
26
|
+
gameplatform connect # prints a URL; a HUMAN completes KYC in a browser
|
|
27
|
+
gameplatform connect # run again to confirm it says COMPLETE
|
|
28
|
+
|
|
29
|
+
# 3. Publish (optionally set pricing in the same step)
|
|
30
|
+
gameplatform deploy game.zip --title "My Game" --pricing paid --price 500 --agree-terms
|
|
31
|
+
|
|
32
|
+
# 4. Pricing later / changes
|
|
33
|
+
gameplatform pricing <game-id> --model stamina --max 3 --regen 480 --refill 100 --buyout 500
|
|
34
|
+
gameplatform pricing <game-id> --model free
|
|
35
|
+
|
|
36
|
+
# 5. Sales & payouts
|
|
37
|
+
gameplatform earnings
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
You keep **80%** of sales (20% platform fee). Payouts run **automatically on the
|
|
41
|
+
1st of each month** to your bank (sales from the last 7 days are held for refund
|
|
42
|
+
protection). You never share your bank details with the platform — Stripe holds
|
|
43
|
+
them.
|
|
44
|
+
|
|
45
|
+
## Commands
|
|
46
|
+
|
|
47
|
+
| Command | Description |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `login [--browser]` | Authenticate |
|
|
50
|
+
| `connect` | Create/check Stripe Connect; prints the KYC URL |
|
|
51
|
+
| `deploy <zip> --title ... [--pricing ...] --agree-terms` | Submit a game |
|
|
52
|
+
| `pricing <game-id> --model free\|paid\|stamina ...` | Set monetization (JPY) |
|
|
53
|
+
| `update <game-id> <zip> --ver ...` | Submit an update |
|
|
54
|
+
| `promote <game-id>` | Publish a staged version |
|
|
55
|
+
| `earnings` | Sales & payout status |
|
|
56
|
+
| `games` / `status` | List games / submission status |
|
|
57
|
+
| `thumbnail` / `screenshot` / `video` | Upload media |
|
|
58
|
+
| `delete <game-id>` | Delete a game |
|
|
59
|
+
| `logout` | Clear credentials |
|
|
60
|
+
|
|
61
|
+
Full docs: https://game-platform-3eg.pages.dev/developers
|
|
62
|
+
|
|
63
|
+
## Notes
|
|
64
|
+
|
|
65
|
+
- ZIPs must use forward-slash paths (`zip -r`, not Windows "Compress-Archive").
|
|
66
|
+
The root must contain `index.html`.
|
|
67
|
+
- `--agree-terms` affirms the
|
|
68
|
+
[creator agreement](https://game-platform-3eg.pages.dev/creator-terms);
|
|
69
|
+
it is required to submit.
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { loginCommand } from '../src/commands/login.js';
|
|
5
|
+
import { deployCommand } from '../src/commands/deploy.js';
|
|
6
|
+
import { updateCommand } from '../src/commands/update.js';
|
|
7
|
+
import { promoteCommand } from '../src/commands/promote.js';
|
|
8
|
+
import { gamesCommand } from '../src/commands/games.js';
|
|
9
|
+
import { statusCommand } from '../src/commands/status.js';
|
|
10
|
+
import { logoutCommand } from '../src/commands/logout.js';
|
|
11
|
+
import { deleteCommand } from '../src/commands/delete.js';
|
|
12
|
+
import { thumbnailCommand } from '../src/commands/thumbnail.js';
|
|
13
|
+
import { screenshotCommand } from '../src/commands/screenshot.js';
|
|
14
|
+
import { videoCommand } from '../src/commands/video.js';
|
|
15
|
+
import { connectCommand, earningsCommand } from '../src/commands/connect.js';
|
|
16
|
+
import { pricingCommand } from '../src/commands/pricing.js';
|
|
17
|
+
|
|
18
|
+
const program = new Command();
|
|
19
|
+
|
|
20
|
+
program
|
|
21
|
+
.name('gameplatform')
|
|
22
|
+
.description('Game Platform CLI — Deploy and manage your games')
|
|
23
|
+
.version('1.0.0');
|
|
24
|
+
|
|
25
|
+
program
|
|
26
|
+
.command('login')
|
|
27
|
+
.description('Authenticate with Game Platform')
|
|
28
|
+
.option('--browser', 'Login via browser (Google OAuth)')
|
|
29
|
+
.action(loginCommand);
|
|
30
|
+
|
|
31
|
+
program
|
|
32
|
+
.command('deploy <zip>')
|
|
33
|
+
.description('Submit a new game (optionally set pricing in the same step)')
|
|
34
|
+
.requiredOption('--title <title>', 'Game title')
|
|
35
|
+
.option('--genre <genre>', 'Game genre', 'casual')
|
|
36
|
+
.option('--description <desc>', 'Game description', '')
|
|
37
|
+
.option('--ver <ver>', 'Game version (semver, e.g. 1.0.0)', '1.0.0')
|
|
38
|
+
.option('--draft', 'Upload as draft (not public, creator-only preview)')
|
|
39
|
+
.option('--agree-terms', 'Affirm the creator agreement (required to submit)')
|
|
40
|
+
.option('--pricing <model>', 'Monetization: free | paid | stamina')
|
|
41
|
+
.option('--price <jpy>', 'Buyout price in JPY (with --pricing paid)')
|
|
42
|
+
.option('--max <n>', 'Stamina max (with --pricing stamina)')
|
|
43
|
+
.option('--regen <minutes>', 'Minutes to regenerate 1 stamina (with --pricing stamina)')
|
|
44
|
+
.option('--refill <jpy>', 'Price to buy 1 stamina refill, JPY (stamina, optional)')
|
|
45
|
+
.option('--buyout <jpy>', 'Buyout price to remove the limit, JPY (stamina, optional)')
|
|
46
|
+
.action(deployCommand);
|
|
47
|
+
|
|
48
|
+
program
|
|
49
|
+
.command('update <game-id> <zip>')
|
|
50
|
+
.description('Submit a game update (version must be different from current)')
|
|
51
|
+
.requiredOption('--ver <ver>', 'New version number (must differ from current)')
|
|
52
|
+
.option('--stage', 'Upload as a creator-only preview version (public keeps the current version)')
|
|
53
|
+
.option('--confirmed', 'Confirm the pre-release checklist (required for direct publish without --stage)')
|
|
54
|
+
.action(updateCommand);
|
|
55
|
+
|
|
56
|
+
program
|
|
57
|
+
.command('promote <game-id>')
|
|
58
|
+
.description('Publish the staged version to the public (staging -> current)')
|
|
59
|
+
.option('--confirmed', 'Confirm the pre-release checklist (required to publish)')
|
|
60
|
+
.action(promoteCommand);
|
|
61
|
+
|
|
62
|
+
program
|
|
63
|
+
.command('connect')
|
|
64
|
+
.description('Set up / check Stripe Connect (payouts). Prints an onboarding URL for the one human KYC step.')
|
|
65
|
+
.action(connectCommand);
|
|
66
|
+
|
|
67
|
+
program
|
|
68
|
+
.command('earnings')
|
|
69
|
+
.description('Show your sales and payout status')
|
|
70
|
+
.action(earningsCommand);
|
|
71
|
+
|
|
72
|
+
program
|
|
73
|
+
.command('pricing <game-id>')
|
|
74
|
+
.description('Set monetization (free / buyout / stamina) in JPY')
|
|
75
|
+
.requiredOption('--model <model>', 'free | paid | stamina')
|
|
76
|
+
.option('--price <jpy>', 'Buyout price in JPY (paid)')
|
|
77
|
+
.option('--max <n>', 'Stamina max (stamina)')
|
|
78
|
+
.option('--regen <minutes>', 'Minutes to regenerate 1 stamina (stamina)')
|
|
79
|
+
.option('--refill <jpy>', 'Price to buy 1 stamina refill, JPY (stamina, optional)')
|
|
80
|
+
.option('--buyout <jpy>', 'Buyout price to remove the limit, JPY (stamina, optional)')
|
|
81
|
+
.action(pricingCommand);
|
|
82
|
+
|
|
83
|
+
program
|
|
84
|
+
.command('games')
|
|
85
|
+
.description('List your games')
|
|
86
|
+
.action(gamesCommand);
|
|
87
|
+
|
|
88
|
+
program
|
|
89
|
+
.command('status')
|
|
90
|
+
.description('Check submission status')
|
|
91
|
+
.action(statusCommand);
|
|
92
|
+
|
|
93
|
+
program
|
|
94
|
+
.command('delete <game-id>')
|
|
95
|
+
.description('Delete your game and all its data')
|
|
96
|
+
.action(deleteCommand);
|
|
97
|
+
|
|
98
|
+
program
|
|
99
|
+
.command('thumbnail <game-id> <image>')
|
|
100
|
+
.description('Upload game thumbnail (png/jpg/webp, max 2MB)')
|
|
101
|
+
.action(thumbnailCommand);
|
|
102
|
+
|
|
103
|
+
program
|
|
104
|
+
.command('screenshot <game-id> <image>')
|
|
105
|
+
.description('Add a screenshot to the game gallery (png/jpg/webp, max 4MB, up to 8). Run multiple times for multiple shots.')
|
|
106
|
+
.action(screenshotCommand);
|
|
107
|
+
|
|
108
|
+
program
|
|
109
|
+
.command('video <game-id> <video>')
|
|
110
|
+
.description('Upload a short preview video (mp4/webm, max 20MB) for the detail page')
|
|
111
|
+
.action(videoCommand);
|
|
112
|
+
|
|
113
|
+
program
|
|
114
|
+
.command('logout')
|
|
115
|
+
.description('Clear saved credentials')
|
|
116
|
+
.action(logoutCommand);
|
|
117
|
+
|
|
118
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gameplatform-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI to publish and monetize browser games on GamePlatform — deploy, set pricing, connect Stripe payouts, and check earnings from the terminal (AI-assistant friendly).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"gameplatform": "./bin/gameplatform.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"gameplatform",
|
|
19
|
+
"games",
|
|
20
|
+
"cli",
|
|
21
|
+
"publish",
|
|
22
|
+
"stripe",
|
|
23
|
+
"indie"
|
|
24
|
+
],
|
|
25
|
+
"homepage": "https://game-platform-3eg.pages.dev/developers",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/KanW123/GamePlatform.git",
|
|
29
|
+
"directory": "cli"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/KanW123/GamePlatform/issues"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"commander": "^12.0.0",
|
|
37
|
+
"open": "^10.0.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { config } from './config.js';
|
|
2
|
+
import { getValidToken } from './auth.js';
|
|
3
|
+
|
|
4
|
+
export async function apiRequest(path, options = {}) {
|
|
5
|
+
const token = await getValidToken();
|
|
6
|
+
if (!token) {
|
|
7
|
+
console.error('Session expired. Run: gameplatform login');
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const url = `${config.API_BASE}${path}`;
|
|
12
|
+
const headers = {
|
|
13
|
+
'Authorization': `Bearer ${token}`,
|
|
14
|
+
...options.headers,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const res = await fetch(url, { ...options, headers });
|
|
18
|
+
if (!res.ok) {
|
|
19
|
+
const err = await res.json().catch(() => ({ error: { message: res.statusText } }));
|
|
20
|
+
throw new Error(err.error?.message || `Request failed: ${res.status}`);
|
|
21
|
+
}
|
|
22
|
+
if (res.status === 204) return null;
|
|
23
|
+
return res.json();
|
|
24
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { config } from './config.js';
|
|
5
|
+
|
|
6
|
+
const CRED_DIR = join(homedir(), '.gameplatform');
|
|
7
|
+
const CRED_FILE = join(CRED_DIR, 'credentials.json');
|
|
8
|
+
|
|
9
|
+
export function saveCredentials(session) {
|
|
10
|
+
if (!existsSync(CRED_DIR)) {
|
|
11
|
+
mkdirSync(CRED_DIR, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
const data = {
|
|
14
|
+
access_token: session.access_token,
|
|
15
|
+
refresh_token: session.refresh_token,
|
|
16
|
+
expires_at: session.expires_at || (Date.now() / 1000 + 3600),
|
|
17
|
+
user: session.user ? {
|
|
18
|
+
id: session.user.id,
|
|
19
|
+
email: session.user.email,
|
|
20
|
+
display_name: session.user.user_metadata?.display_name || session.user.email?.split('@')[0],
|
|
21
|
+
} : null,
|
|
22
|
+
};
|
|
23
|
+
writeFileSync(CRED_FILE, JSON.stringify(data, null, 2));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function loadCredentials() {
|
|
27
|
+
if (!existsSync(CRED_FILE)) return null;
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(readFileSync(CRED_FILE, 'utf-8'));
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function clearCredentials() {
|
|
36
|
+
if (existsSync(CRED_FILE)) unlinkSync(CRED_FILE);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function getValidToken() {
|
|
40
|
+
const creds = loadCredentials();
|
|
41
|
+
if (!creds) return null;
|
|
42
|
+
|
|
43
|
+
// Check if token is expired (with 60s buffer)
|
|
44
|
+
const now = Date.now() / 1000;
|
|
45
|
+
if (creds.expires_at && creds.expires_at - 60 > now) {
|
|
46
|
+
return creds.access_token;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Try refresh
|
|
50
|
+
if (creds.refresh_token) {
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(`${config.SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: {
|
|
55
|
+
'Content-Type': 'application/json',
|
|
56
|
+
'apikey': config.SUPABASE_ANON_KEY,
|
|
57
|
+
},
|
|
58
|
+
body: JSON.stringify({ refresh_token: creds.refresh_token }),
|
|
59
|
+
});
|
|
60
|
+
if (res.ok) {
|
|
61
|
+
const data = await res.json();
|
|
62
|
+
saveCredentials(data);
|
|
63
|
+
return data.access_token;
|
|
64
|
+
}
|
|
65
|
+
} catch { /* fall through */ }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function requireAuth() {
|
|
72
|
+
const token = await getValidToken();
|
|
73
|
+
if (!token) {
|
|
74
|
+
console.error('Not logged in or session expired. Run: gameplatform login');
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
const creds = loadCredentials();
|
|
78
|
+
return { ...creds, access_token: token };
|
|
79
|
+
}
|
package/src/checklist.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Pre-release checklist shown before a public release (direct publish / promote).
|
|
2
|
+
// Non-interactive by design: the command refuses without --confirmed and prints
|
|
3
|
+
// this list, so both humans and AI agents see it and must consciously re-run.
|
|
4
|
+
export const RELEASE_CHECKLIST = [
|
|
5
|
+
'セーブ形式を変えた場合、旧セーブのマイグレーション(後方互換)に対応した',
|
|
6
|
+
'「検証版をプレイ」で自分の既存セーブが正常に読めることを確認した',
|
|
7
|
+
'起動・対戦・課金導線など主要な動作を確認した',
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
export function printReleaseChecklist(rerunCmd) {
|
|
11
|
+
console.error('\n⚠ リリース前チェック(未確認のため中止しました)');
|
|
12
|
+
for (const item of RELEASE_CHECKLIST) console.error(` [ ] ${item}`);
|
|
13
|
+
console.error('\n※ 自動で互換性は判定できません。検証版を自分の既存セーブで起動して確認してください。');
|
|
14
|
+
console.error(`確認できたら再実行: ${rerunCmd}\n`);
|
|
15
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { apiRequest } from '../api.js';
|
|
2
|
+
|
|
3
|
+
// `gameplatform connect` — one smart command for the whole Stripe Connect setup.
|
|
4
|
+
// AI-friendly: the agent runs it, hands the printed URL to the human for the one
|
|
5
|
+
// unavoidable human step (identity/bank KYC), then runs it again to confirm.
|
|
6
|
+
// - no account yet -> creates the Express account, prints the onboarding URL
|
|
7
|
+
// - onboarding open -> prints the resume URL
|
|
8
|
+
// - complete -> prints status + balances
|
|
9
|
+
export async function connectCommand() {
|
|
10
|
+
const status = await apiRequest('/developer/connect/status');
|
|
11
|
+
|
|
12
|
+
if (!status.connected) {
|
|
13
|
+
console.log('Stripe Connect: not set up yet. Creating your connected account...');
|
|
14
|
+
const { onboarding_url } = await apiRequest('/developer/connect/create', {
|
|
15
|
+
method: 'POST',
|
|
16
|
+
headers: { 'Content-Type': 'application/json' },
|
|
17
|
+
body: '{}',
|
|
18
|
+
});
|
|
19
|
+
printOnboarding(onboarding_url);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (status.onboarding_status !== 'complete') {
|
|
24
|
+
console.log('Stripe Connect: onboarding not finished yet.');
|
|
25
|
+
const { onboarding_url } = await apiRequest('/developer/connect/onboarding-link');
|
|
26
|
+
printOnboarding(onboarding_url);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Complete.
|
|
31
|
+
console.log(
|
|
32
|
+
`\n Stripe Connect: COMPLETE` +
|
|
33
|
+
(status.payouts_enabled
|
|
34
|
+
? ' (payouts enabled)'
|
|
35
|
+
: ' (payouts NOT enabled yet — open the Stripe dashboard and finish the remaining items)')
|
|
36
|
+
);
|
|
37
|
+
console.log(` Earned : ¥${(status.total_earned || 0).toLocaleString()}`);
|
|
38
|
+
console.log(` Unsettled: ¥${(status.unsettled_balance || 0).toLocaleString()} (paid out automatically on the 1st of each month)`);
|
|
39
|
+
console.log(` Paid out : ¥${(status.total_settled || 0).toLocaleString()}`);
|
|
40
|
+
console.log(`\n You're ready to publish. Next: gameplatform deploy <your-game.zip> --title "..." --pricing ...\n`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function printOnboarding(url) {
|
|
44
|
+
console.log('');
|
|
45
|
+
console.log(' ===================================================================');
|
|
46
|
+
console.log(' HUMAN STEP REQUIRED (an AI cannot do this — it is legal identity KYC)');
|
|
47
|
+
console.log(' Open this URL in a browser and complete identity + bank verification:');
|
|
48
|
+
console.log('');
|
|
49
|
+
console.log(' ' + url);
|
|
50
|
+
console.log('');
|
|
51
|
+
console.log(' - Already have a Stripe account? It becomes a quick "authorize".');
|
|
52
|
+
console.log(' - New to Stripe? ~5 minutes (ID + bank account).');
|
|
53
|
+
console.log(' When finished, run: gameplatform connect (to confirm it says COMPLETE)');
|
|
54
|
+
console.log(' ===================================================================');
|
|
55
|
+
console.log('');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// `gameplatform earnings` — show sales + payout status.
|
|
59
|
+
export async function earningsCommand() {
|
|
60
|
+
const [status, earnings] = await Promise.all([
|
|
61
|
+
apiRequest('/developer/connect/status').catch(() => null),
|
|
62
|
+
apiRequest('/developer/earnings'),
|
|
63
|
+
]);
|
|
64
|
+
const { total_earned = 0, total_settled = 0, unsettled_balance = 0, by_game = [] } = earnings || {};
|
|
65
|
+
|
|
66
|
+
console.log('\n Earnings');
|
|
67
|
+
console.log(' ' + '-'.repeat(50));
|
|
68
|
+
console.log(` Total earned : ¥${total_earned.toLocaleString()}`);
|
|
69
|
+
console.log(` Unsettled : ¥${unsettled_balance.toLocaleString()} (next monthly payout; last 7 days held for refunds)`);
|
|
70
|
+
console.log(` Paid out : ¥${total_settled.toLocaleString()}`);
|
|
71
|
+
if (status && status.connected && !status.payouts_enabled) {
|
|
72
|
+
console.log(' ! payouts not enabled yet — run "gameplatform connect" to finish onboarding.');
|
|
73
|
+
}
|
|
74
|
+
if (by_game.length) {
|
|
75
|
+
console.log('\n Per game:');
|
|
76
|
+
for (const g of by_game) {
|
|
77
|
+
console.log(` ${String(g.game_id).padEnd(24)} x${g.count} gross ¥${g.gross.toLocaleString()} you ¥${g.net.toLocaleString()}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
console.log();
|
|
81
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createInterface } from 'readline';
|
|
2
|
+
import { apiRequest } from '../api.js';
|
|
3
|
+
|
|
4
|
+
export async function deleteCommand(gameId) {
|
|
5
|
+
// Confirm
|
|
6
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
7
|
+
const answer = await new Promise(resolve => {
|
|
8
|
+
rl.question(`Delete game "${gameId}" and all its data? This cannot be undone. (yes/no): `, resolve);
|
|
9
|
+
});
|
|
10
|
+
rl.close();
|
|
11
|
+
|
|
12
|
+
if (answer.toLowerCase() !== 'yes') {
|
|
13
|
+
console.log('Cancelled.');
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
console.log(`Deleting ${gameId}...`);
|
|
18
|
+
const result = await apiRequest(`/developer/games/${encodeURIComponent(gameId)}`, {
|
|
19
|
+
method: 'DELETE',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
console.log(`Deleted: ${result.game_id}`);
|
|
23
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'fs';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import { config } from '../config.js';
|
|
4
|
+
import { getValidToken } from '../auth.js';
|
|
5
|
+
|
|
6
|
+
export async function deployCommand(zipPath, options) {
|
|
7
|
+
const token = await getValidToken();
|
|
8
|
+
if (!token) {
|
|
9
|
+
console.error('Not logged in. Run: gameplatform login');
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const filePath = resolve(zipPath);
|
|
14
|
+
let stat;
|
|
15
|
+
try {
|
|
16
|
+
stat = statSync(filePath);
|
|
17
|
+
} catch {
|
|
18
|
+
console.error(`File not found: ${filePath}`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (stat.size > 50 * 1024 * 1024) {
|
|
23
|
+
console.error('ZIP file exceeds 50MB limit');
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!options.title) {
|
|
28
|
+
console.error('--title is required');
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Creator terms consent (the API requires the current version). Passing
|
|
33
|
+
// --agree-terms affirms the creator agreement; without it we refuse.
|
|
34
|
+
if (!options.agreeTerms) {
|
|
35
|
+
console.error('クリエイター規約への同意が必要です。');
|
|
36
|
+
console.error(`規約: ${config.PLATFORM_URL}/creator-terms`);
|
|
37
|
+
console.error('内容を確認のうえ、同意して再実行してください: gameplatform deploy ... --agree-terms');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const fileBuffer = readFileSync(filePath);
|
|
42
|
+
const sizeMB = (stat.size / 1024 / 1024).toFixed(1);
|
|
43
|
+
console.log(`Uploading ${options.title} (${sizeMB}MB)...`);
|
|
44
|
+
|
|
45
|
+
const params = {
|
|
46
|
+
title: options.title,
|
|
47
|
+
genre: options.genre || 'casual',
|
|
48
|
+
description: options.description || '',
|
|
49
|
+
version: options.ver || '1.0.0',
|
|
50
|
+
terms_version: config.CREATOR_TERMS_VERSION,
|
|
51
|
+
};
|
|
52
|
+
if (options.draft) params.draft = 'true';
|
|
53
|
+
const qs = new URLSearchParams(params);
|
|
54
|
+
|
|
55
|
+
const res = await fetch(`${config.API_BASE}/submissions?${qs}`, {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: {
|
|
58
|
+
'Content-Type': 'application/zip',
|
|
59
|
+
'Authorization': `Bearer ${token}`,
|
|
60
|
+
},
|
|
61
|
+
body: fileBuffer,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const data = await res.json();
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
console.error('Upload failed:', data.error?.message || 'Unknown error');
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (data.validation?.errors?.length > 0) {
|
|
71
|
+
console.error('Validation errors:');
|
|
72
|
+
data.validation.errors.forEach(e => console.error(` - ${e}`));
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
console.log(data.draft ? 'Uploaded as draft!' : 'Published!');
|
|
77
|
+
console.log(` Game ID: ${data.game_id || data.submission?.id}`);
|
|
78
|
+
console.log(` Status: ${data.draft ? 'draft (preview only)' : data.published ? 'published' : data.submission?.status}`);
|
|
79
|
+
|
|
80
|
+
if (data.validation?.warnings?.length > 0) {
|
|
81
|
+
console.log(' Warnings:');
|
|
82
|
+
data.validation.warnings.forEach(w => console.log(` - ${w}`));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Optional: set monetization in the same step. Only works once the game row
|
|
86
|
+
// exists (direct publish). If it went to review, set it after approval with
|
|
87
|
+
// `gameplatform pricing <game-id> --model ...`.
|
|
88
|
+
if (options.pricing && options.pricing !== 'free') {
|
|
89
|
+
const gameId = data.game_id;
|
|
90
|
+
if (!gameId) {
|
|
91
|
+
console.log(`\n 承認後に価格を設定してください: gameplatform pricing <game-id> --model ${options.pricing} ...`);
|
|
92
|
+
} else {
|
|
93
|
+
try {
|
|
94
|
+
const { applyPricing } = await import('./pricing.js');
|
|
95
|
+
const res = await applyPricing(gameId, options);
|
|
96
|
+
console.log(` Pricing: ${res.pricing}`);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error(` 価格未設定: ${err.message}`);
|
|
99
|
+
console.error(` 手動で: gameplatform pricing ${gameId} --model ${options.pricing} ...`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { apiRequest } from '../api.js';
|
|
2
|
+
|
|
3
|
+
export async function gamesCommand() {
|
|
4
|
+
const { games } = await apiRequest('/developer/games');
|
|
5
|
+
|
|
6
|
+
if (!games || games.length === 0) {
|
|
7
|
+
console.log('No games found. Deploy one with: gameplatform deploy <zip> --title "My Game"');
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
console.log(`\n ${'ID'.padEnd(25)} ${'TITLE'.padEnd(25)} ${'VERSION'.padEnd(10)} ${'STATUS'.padEnd(12)} GENRE`);
|
|
12
|
+
console.log(' ' + '-'.repeat(85));
|
|
13
|
+
|
|
14
|
+
for (const g of games) {
|
|
15
|
+
const status = g.taken_down_reason ? 'TAKEN DOWN' : g.status.toUpperCase();
|
|
16
|
+
console.log(
|
|
17
|
+
` ${(g.id || '').padEnd(25)} ${(g.title || '').padEnd(25)} ${('v' + (g.current_version || '?')).padEnd(10)} ${status.padEnd(12)} ${g.genre || ''}`
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
console.log();
|
|
21
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { createInterface } from 'readline';
|
|
2
|
+
import { createServer } from 'http';
|
|
3
|
+
import { config } from '../config.js';
|
|
4
|
+
import { saveCredentials, loadCredentials, clearCredentials, getValidToken } from '../auth.js';
|
|
5
|
+
|
|
6
|
+
export async function loginCommand(options) {
|
|
7
|
+
// Already logged in? Only short-circuit if the session is still valid/refreshable.
|
|
8
|
+
const existing = loadCredentials();
|
|
9
|
+
if (existing?.user) {
|
|
10
|
+
const token = await getValidToken(); // refreshes if possible
|
|
11
|
+
if (token) {
|
|
12
|
+
console.log(`Already logged in as ${existing.user.display_name} (${existing.user.email})`);
|
|
13
|
+
console.log('Run "gameplatform logout" first to switch accounts.');
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
// Stored session expired and couldn't be refreshed — clear it and sign in again.
|
|
17
|
+
console.log(`Session for ${existing.user.email} expired — signing in again...`);
|
|
18
|
+
clearCredentials();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (options.browser) {
|
|
22
|
+
await browserLogin();
|
|
23
|
+
} else {
|
|
24
|
+
await emailLogin();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function emailLogin() {
|
|
29
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
30
|
+
const ask = (q) => new Promise(resolve => rl.question(q, resolve));
|
|
31
|
+
|
|
32
|
+
const email = await ask('Email: ');
|
|
33
|
+
const password = await askPassword('Password: ');
|
|
34
|
+
rl.close();
|
|
35
|
+
|
|
36
|
+
console.log('\nAuthenticating...');
|
|
37
|
+
|
|
38
|
+
const res = await fetch(`${config.SUPABASE_URL}/auth/v1/token?grant_type=password`, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: {
|
|
41
|
+
'Content-Type': 'application/json',
|
|
42
|
+
'apikey': config.SUPABASE_ANON_KEY,
|
|
43
|
+
},
|
|
44
|
+
body: JSON.stringify({ email, password }),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
const err = await res.json().catch(() => ({}));
|
|
49
|
+
console.error('Login failed:', err.error_description || err.msg || 'Unknown error');
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const data = await res.json();
|
|
54
|
+
saveCredentials(data);
|
|
55
|
+
|
|
56
|
+
const name = data.user?.user_metadata?.display_name || email.split('@')[0];
|
|
57
|
+
console.log(`Logged in as ${name} (${email})`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function browserLogin() {
|
|
61
|
+
const open = (await import('open')).default;
|
|
62
|
+
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
const server = createServer((req, res) => {
|
|
65
|
+
const url = new URL(req.url, 'http://localhost');
|
|
66
|
+
|
|
67
|
+
if (url.pathname === '/' && req.method === 'GET') {
|
|
68
|
+
// Serve a page that extracts tokens from URL hash
|
|
69
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
70
|
+
res.end(`<!DOCTYPE html>
|
|
71
|
+
<html><head><title>GamePlatform CLI</title></head>
|
|
72
|
+
<body style="font-family:monospace;background:#060a10;color:#0ff;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0">
|
|
73
|
+
<div style="text-align:center">
|
|
74
|
+
<h2>Authenticating...</h2>
|
|
75
|
+
<p id="status">Processing tokens...</p>
|
|
76
|
+
<script>
|
|
77
|
+
const hash = window.location.hash.substring(1);
|
|
78
|
+
const params = new URLSearchParams(hash);
|
|
79
|
+
const access_token = params.get('access_token');
|
|
80
|
+
const refresh_token = params.get('refresh_token');
|
|
81
|
+
if (access_token) {
|
|
82
|
+
fetch('/receive-token', {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'Content-Type': 'application/json' },
|
|
85
|
+
body: JSON.stringify({ access_token, refresh_token })
|
|
86
|
+
}).then(() => {
|
|
87
|
+
document.getElementById('status').textContent = 'Login successful! You can close this tab.';
|
|
88
|
+
});
|
|
89
|
+
} else {
|
|
90
|
+
document.getElementById('status').textContent = 'No token received. Please try again.';
|
|
91
|
+
}
|
|
92
|
+
</script>
|
|
93
|
+
</div></body></html>`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (url.pathname === '/receive-token' && req.method === 'POST') {
|
|
98
|
+
let body = '';
|
|
99
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
100
|
+
req.on('end', async () => {
|
|
101
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
102
|
+
res.end('{"ok":true}');
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const tokens = JSON.parse(body);
|
|
106
|
+
|
|
107
|
+
// Fetch user info with the token
|
|
108
|
+
const userRes = await fetch(`${config.SUPABASE_URL}/auth/v1/user`, {
|
|
109
|
+
headers: {
|
|
110
|
+
'Authorization': `Bearer ${tokens.access_token}`,
|
|
111
|
+
'apikey': config.SUPABASE_ANON_KEY,
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
const user = userRes.ok ? await userRes.json() : null;
|
|
115
|
+
|
|
116
|
+
saveCredentials({
|
|
117
|
+
access_token: tokens.access_token,
|
|
118
|
+
refresh_token: tokens.refresh_token,
|
|
119
|
+
expires_at: Date.now() / 1000 + 3600,
|
|
120
|
+
user,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const name = user?.user_metadata?.display_name || user?.email?.split('@')[0] || 'User';
|
|
124
|
+
console.log(`\nLogged in as ${name} (${user?.email || 'unknown'})`);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
console.error('\nLogin failed:', err.message);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
server.close();
|
|
130
|
+
resolve();
|
|
131
|
+
});
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
res.writeHead(404);
|
|
136
|
+
res.end('Not found');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
server.listen(0, () => {
|
|
140
|
+
const port = server.address().port;
|
|
141
|
+
const callbackUrl = `http://localhost:${port}`;
|
|
142
|
+
const authUrl = `${config.SUPABASE_URL}/auth/v1/authorize?provider=google&redirect_to=${encodeURIComponent(callbackUrl)}`;
|
|
143
|
+
|
|
144
|
+
console.log(`Opening browser for authentication...`);
|
|
145
|
+
console.log(`If browser doesn't open, visit: ${authUrl}`);
|
|
146
|
+
open(authUrl);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function askPassword(prompt) {
|
|
152
|
+
return new Promise((resolve) => {
|
|
153
|
+
const stdin = process.stdin;
|
|
154
|
+
|
|
155
|
+
// If not a TTY (piped input), use readline
|
|
156
|
+
if (!stdin.isTTY) {
|
|
157
|
+
const rl = createInterface({ input: stdin, output: process.stdout });
|
|
158
|
+
rl.question(prompt, (answer) => { rl.close(); resolve(answer); });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
process.stdout.write(prompt);
|
|
163
|
+
stdin.setRawMode(true);
|
|
164
|
+
stdin.resume();
|
|
165
|
+
stdin.setEncoding('utf8');
|
|
166
|
+
|
|
167
|
+
let password = '';
|
|
168
|
+
const onData = (ch) => {
|
|
169
|
+
if (ch === '\r' || ch === '\n') {
|
|
170
|
+
stdin.setRawMode(false);
|
|
171
|
+
stdin.removeListener('data', onData);
|
|
172
|
+
stdin.pause();
|
|
173
|
+
resolve(password);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (ch === '\u0003') { // Ctrl+C
|
|
177
|
+
process.exit();
|
|
178
|
+
}
|
|
179
|
+
if (ch === '\u007f' || ch === '\b') { // Backspace
|
|
180
|
+
if (password.length > 0) {
|
|
181
|
+
password = password.slice(0, -1);
|
|
182
|
+
process.stdout.write('\b \b');
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
password += ch;
|
|
187
|
+
process.stdout.write('*');
|
|
188
|
+
};
|
|
189
|
+
stdin.on('data', onData);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { apiRequest } from '../api.js';
|
|
2
|
+
|
|
3
|
+
// Build the pricing request body from CLI options and POST it.
|
|
4
|
+
// Shared by `gameplatform pricing` and the `deploy --pricing ...` post-step.
|
|
5
|
+
export async function applyPricing(gameId, options) {
|
|
6
|
+
const model = options.pricing || options.model;
|
|
7
|
+
if (!['free', 'paid', 'stamina'].includes(model)) {
|
|
8
|
+
throw new Error('--pricing/--model must be: free | paid | stamina');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const body = { model };
|
|
12
|
+
if (model === 'paid') {
|
|
13
|
+
const price = parseInt(options.price, 10);
|
|
14
|
+
if (!price || price < 50) throw new Error('paid requires --price <jpy> (>= 50)');
|
|
15
|
+
body.price_jpy = price;
|
|
16
|
+
} else if (model === 'stamina') {
|
|
17
|
+
const max = parseInt(options.max, 10);
|
|
18
|
+
const regen = parseInt(options.regen, 10);
|
|
19
|
+
if (!max || max < 1) throw new Error('stamina requires --max <n> (>= 1)');
|
|
20
|
+
if (!regen || regen < 1) throw new Error('stamina requires --regen <minutes> (>= 1)');
|
|
21
|
+
body.stamina_max = max;
|
|
22
|
+
body.stamina_regen_minutes = regen;
|
|
23
|
+
body.stamina_refill_price_jpy = options.refill ? parseInt(options.refill, 10) : 0;
|
|
24
|
+
if (options.buyout) body.price_jpy = parseInt(options.buyout, 10);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return apiRequest(`/developer/games/${gameId}/pricing`, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: { 'Content-Type': 'application/json' },
|
|
30
|
+
body: JSON.stringify(body),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function pricingCommand(gameId, options) {
|
|
35
|
+
try {
|
|
36
|
+
const res = await applyPricing(gameId, options);
|
|
37
|
+
console.log(`Pricing set: ${gameId} -> ${res.pricing}`);
|
|
38
|
+
if (options.model === 'paid') console.log(` Buyout: ¥${options.price}`);
|
|
39
|
+
if (options.model === 'stamina') {
|
|
40
|
+
console.log(` Stamina: max ${options.max}, +1 every ${options.regen} min`);
|
|
41
|
+
if (options.refill) console.log(` Refill: ¥${options.refill} per stamina`);
|
|
42
|
+
if (options.buyout) console.log(` Buyout (remove limit): ¥${options.buyout}`);
|
|
43
|
+
}
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error('Pricing failed:', err.message);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { config } from '../config.js';
|
|
2
|
+
import { getValidToken } from '../auth.js';
|
|
3
|
+
import { printReleaseChecklist } from '../checklist.js';
|
|
4
|
+
|
|
5
|
+
// Publish the staged version (staging_version -> current_version).
|
|
6
|
+
// Gated by --confirmed: without it, print the pre-release checklist and stop.
|
|
7
|
+
export async function promoteCommand(gameId, options) {
|
|
8
|
+
// Gate first, before any token/network work, so the refusal path stays sync-clean.
|
|
9
|
+
if (!options.confirmed) {
|
|
10
|
+
printReleaseChecklist(`gameplatform promote ${gameId} --confirmed`);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const token = await getValidToken();
|
|
15
|
+
if (!token) {
|
|
16
|
+
console.error('Not logged in. Run: gameplatform login');
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
console.log(`Promoting staged version of ${gameId} to public...`);
|
|
21
|
+
|
|
22
|
+
const res = await fetch(
|
|
23
|
+
`${config.API_BASE}/developer/games/${encodeURIComponent(gameId)}/promote`,
|
|
24
|
+
{
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: {
|
|
27
|
+
'Content-Type': 'application/json',
|
|
28
|
+
'Authorization': `Bearer ${token}`,
|
|
29
|
+
},
|
|
30
|
+
body: '{}',
|
|
31
|
+
}
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const data = await res.json();
|
|
35
|
+
if (!res.ok) {
|
|
36
|
+
console.error('Promote failed:', data.error?.message || 'Unknown error');
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
console.log('Published!');
|
|
41
|
+
console.log(` Game: ${data.game_id}`);
|
|
42
|
+
console.log(` Now public: v${data.version || data.current_version}`);
|
|
43
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'fs';
|
|
2
|
+
import { resolve, extname } from 'path';
|
|
3
|
+
import { config } from '../config.js';
|
|
4
|
+
import { getValidToken } from '../auth.js';
|
|
5
|
+
|
|
6
|
+
// Upload one screenshot to a game's gallery (run multiple times for multiple shots).
|
|
7
|
+
export async function screenshotCommand(gameId, imagePath) {
|
|
8
|
+
const token = await getValidToken();
|
|
9
|
+
if (!token) {
|
|
10
|
+
console.error('Not logged in. Run: gameplatform login');
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (!gameId || !imagePath) {
|
|
15
|
+
console.error('Usage: gameplatform screenshot <game-id> <image-path>');
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const filePath = resolve(imagePath);
|
|
20
|
+
let stat;
|
|
21
|
+
try {
|
|
22
|
+
stat = statSync(filePath);
|
|
23
|
+
} catch {
|
|
24
|
+
console.error(`File not found: ${filePath}`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (stat.size > 4 * 1024 * 1024) {
|
|
29
|
+
console.error('Screenshot must be under 4MB');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ext = extname(filePath).toLowerCase();
|
|
34
|
+
const mimeMap = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' };
|
|
35
|
+
const contentType = mimeMap[ext];
|
|
36
|
+
if (!contentType) {
|
|
37
|
+
console.error('Supported formats: .png, .jpg, .jpeg, .webp');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const fileBuffer = readFileSync(filePath);
|
|
42
|
+
console.log(`Uploading screenshot for ${gameId}...`);
|
|
43
|
+
|
|
44
|
+
const res = await fetch(`${config.API_BASE}/developer/games/${encodeURIComponent(gameId)}/screenshots`, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: {
|
|
47
|
+
'Content-Type': contentType,
|
|
48
|
+
'Authorization': `Bearer ${token}`,
|
|
49
|
+
},
|
|
50
|
+
body: fileBuffer,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const data = await res.json();
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
console.error('Upload failed:', data.error?.message || 'Unknown error');
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log(`Screenshot uploaded! (${data.screenshots.length} total)`);
|
|
60
|
+
data.screenshots.forEach((s, i) => console.log(` ${i + 1}. ${s}`));
|
|
61
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { apiRequest } from '../api.js';
|
|
2
|
+
|
|
3
|
+
export async function statusCommand() {
|
|
4
|
+
const { submissions } = await apiRequest('/submissions/mine');
|
|
5
|
+
|
|
6
|
+
if (!submissions || submissions.length === 0) {
|
|
7
|
+
console.log('No submissions found.');
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
console.log(`\n ${'TITLE'.padEnd(25)} ${'VERSION'.padEnd(10)} ${'STATUS'.padEnd(12)} ${'DATE'.padEnd(12)} NOTES`);
|
|
12
|
+
console.log(' ' + '-'.repeat(80));
|
|
13
|
+
|
|
14
|
+
for (const s of submissions) {
|
|
15
|
+
const date = new Date(s.created_at).toLocaleDateString();
|
|
16
|
+
const notes = s.rejection_reason || (s.game_id ? `game:${s.game_id}` : '');
|
|
17
|
+
console.log(
|
|
18
|
+
` ${(s.title || '').padEnd(25)} ${('v' + (s.version || '?')).padEnd(10)} ${(s.status || '').toUpperCase().padEnd(12)} ${date.padEnd(12)} ${notes}`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
console.log();
|
|
22
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'fs';
|
|
2
|
+
import { resolve, extname } from 'path';
|
|
3
|
+
import { config } from '../config.js';
|
|
4
|
+
import { getValidToken } from '../auth.js';
|
|
5
|
+
|
|
6
|
+
export async function thumbnailCommand(gameId, imagePath) {
|
|
7
|
+
const token = await getValidToken();
|
|
8
|
+
if (!token) {
|
|
9
|
+
console.error('Not logged in. Run: gameplatform login');
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (!gameId || !imagePath) {
|
|
14
|
+
console.error('Usage: gameplatform thumbnail <game-id> <image-path>');
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const filePath = resolve(imagePath);
|
|
19
|
+
let stat;
|
|
20
|
+
try {
|
|
21
|
+
stat = statSync(filePath);
|
|
22
|
+
} catch {
|
|
23
|
+
console.error(`File not found: ${filePath}`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (stat.size > 2 * 1024 * 1024) {
|
|
28
|
+
console.error('Image must be under 2MB');
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ext = extname(filePath).toLowerCase();
|
|
33
|
+
const mimeMap = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' };
|
|
34
|
+
const contentType = mimeMap[ext];
|
|
35
|
+
if (!contentType) {
|
|
36
|
+
console.error('Supported formats: .png, .jpg, .jpeg, .webp');
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const fileBuffer = readFileSync(filePath);
|
|
41
|
+
console.log(`Uploading thumbnail for ${gameId}...`);
|
|
42
|
+
|
|
43
|
+
const res = await fetch(`${config.API_BASE}/developer/games/${encodeURIComponent(gameId)}/thumbnail`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: {
|
|
46
|
+
'Content-Type': contentType,
|
|
47
|
+
'Authorization': `Bearer ${token}`,
|
|
48
|
+
},
|
|
49
|
+
body: fileBuffer,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const data = await res.json();
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
console.error('Upload failed:', data.error?.message || 'Unknown error');
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log('Thumbnail uploaded!');
|
|
59
|
+
console.log(` URL: ${data.thumbnail_url}`);
|
|
60
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'fs';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import { config } from '../config.js';
|
|
4
|
+
import { getValidToken } from '../auth.js';
|
|
5
|
+
import { printReleaseChecklist } from '../checklist.js';
|
|
6
|
+
|
|
7
|
+
export async function updateCommand(gameId, zipPath, options) {
|
|
8
|
+
if (!options.ver) {
|
|
9
|
+
console.error('--ver is required (e.g. --ver 1.1.0)');
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Direct publish (no --stage) goes live immediately, so require an explicit
|
|
14
|
+
// pre-release confirmation. Refuse-then-confirm keeps this non-interactive
|
|
15
|
+
// (works for humans and AI agents). --stage is safe (creator-only) → no gate.
|
|
16
|
+
// Gate first, before any token/network work, so the refusal path stays sync-clean.
|
|
17
|
+
if (!options.stage && !options.confirmed) {
|
|
18
|
+
printReleaseChecklist(`gameplatform update ${gameId} ${zipPath} --ver ${options.ver} --confirmed`);
|
|
19
|
+
console.error('(公開せず検証版だけ上げるなら --stage を使ってください)');
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const token = await getValidToken();
|
|
24
|
+
if (!token) {
|
|
25
|
+
console.error('Not logged in. Run: gameplatform login');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const filePath = resolve(zipPath);
|
|
30
|
+
let stat;
|
|
31
|
+
try {
|
|
32
|
+
stat = statSync(filePath);
|
|
33
|
+
} catch {
|
|
34
|
+
console.error(`File not found: ${filePath}`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (stat.size > 50 * 1024 * 1024) {
|
|
39
|
+
console.error('ZIP file exceeds 50MB limit');
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const fileBuffer = readFileSync(filePath);
|
|
44
|
+
const sizeMB = (stat.size / 1024 / 1024).toFixed(1);
|
|
45
|
+
const stageQS = options.stage ? '&stage=true' : '';
|
|
46
|
+
console.log(`${options.stage ? 'Staging (creator-only preview)' : 'Updating'} ${gameId} to v${options.ver} (${sizeMB}MB)...`);
|
|
47
|
+
|
|
48
|
+
const res = await fetch(
|
|
49
|
+
`${config.API_BASE}/developer/games/${encodeURIComponent(gameId)}/update?version=${encodeURIComponent(options.ver)}${stageQS}`,
|
|
50
|
+
{
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
'Content-Type': 'application/zip',
|
|
54
|
+
'Authorization': `Bearer ${token}`,
|
|
55
|
+
},
|
|
56
|
+
body: fileBuffer,
|
|
57
|
+
}
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const data = await res.json();
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
console.error('Update failed:', data.error?.message || 'Unknown error');
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log(data.staged ? 'Staged! (creator-only preview — public still on the current version)' : 'Updated!');
|
|
67
|
+
console.log(` Game: ${data.game_id}`);
|
|
68
|
+
console.log(` Version: ${data.version}`);
|
|
69
|
+
console.log(` Files: ${data.files}`);
|
|
70
|
+
if (data.staged) console.log(' Promote it from the developer dashboard when ready.');
|
|
71
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'fs';
|
|
2
|
+
import { resolve, extname } from 'path';
|
|
3
|
+
import { config } from '../config.js';
|
|
4
|
+
import { getValidToken } from '../auth.js';
|
|
5
|
+
|
|
6
|
+
// Upload a short preview video (mp4/webm, max 20MB) for a game's detail page.
|
|
7
|
+
export async function videoCommand(gameId, videoPath) {
|
|
8
|
+
const token = await getValidToken();
|
|
9
|
+
if (!token) {
|
|
10
|
+
console.error('Not logged in. Run: gameplatform login');
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (!gameId || !videoPath) {
|
|
15
|
+
console.error('Usage: gameplatform video <game-id> <video-path>');
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const filePath = resolve(videoPath);
|
|
20
|
+
let stat;
|
|
21
|
+
try {
|
|
22
|
+
stat = statSync(filePath);
|
|
23
|
+
} catch {
|
|
24
|
+
console.error(`File not found: ${filePath}`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (stat.size > 20 * 1024 * 1024) {
|
|
29
|
+
console.error('Video must be under 20MB (use a short, compressed clip)');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ext = extname(filePath).toLowerCase();
|
|
34
|
+
const mimeMap = { '.mp4': 'video/mp4', '.webm': 'video/webm' };
|
|
35
|
+
const contentType = mimeMap[ext];
|
|
36
|
+
if (!contentType) {
|
|
37
|
+
console.error('Supported formats: .mp4, .webm');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const fileBuffer = readFileSync(filePath);
|
|
42
|
+
console.log(`Uploading preview video for ${gameId}...`);
|
|
43
|
+
|
|
44
|
+
const res = await fetch(`${config.API_BASE}/developer/games/${encodeURIComponent(gameId)}/video`, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: {
|
|
47
|
+
'Content-Type': contentType,
|
|
48
|
+
'Authorization': `Bearer ${token}`,
|
|
49
|
+
},
|
|
50
|
+
body: fileBuffer,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const data = await res.json();
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
console.error('Upload failed:', data.error?.message || 'Unknown error');
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log('Preview video uploaded!');
|
|
60
|
+
console.log(` URL: ${data.video_url}`);
|
|
61
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const config = {
|
|
2
|
+
API_BASE: 'https://api-gateway.ailovedirector.workers.dev',
|
|
3
|
+
SUPABASE_URL: 'https://rsjmtctxnmazzhnzfvwz.supabase.co',
|
|
4
|
+
SUPABASE_ANON_KEY: 'sb_publishable_Vn4pzBqLOPKxsvfm6GCA2g_iRoVkL-b',
|
|
5
|
+
PLATFORM_URL: 'https://game-platform-3eg.pages.dev',
|
|
6
|
+
// Must match submissions.ts / platform config CREATOR_TERMS_VERSION. Bump in
|
|
7
|
+
// all three when the creator agreement text changes (forces re-consent).
|
|
8
|
+
CREATOR_TERMS_VERSION: '2026-06-17',
|
|
9
|
+
};
|