magpie-gemini-web 0.5.0 → 0.5.2
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/LICENSE-AISTUDIO +21 -0
- package/README.en.md +108 -125
- package/README.md +118 -135
- package/docs/studio-release.md +49 -0
- package/lib/studio-native.mjs +218 -45
- package/lib/studio-release.json +26 -15
- package/lib/studio.mjs +135 -94
- package/package.json +9 -2
- package/patches/studio-managed.patch +625 -0
- package/scripts/studio-archive.go +92 -0
- package/scripts/studio-managed-smoke.mjs +66 -0
- package/scripts/studio-release.mjs +85 -0
- package/scripts/studio.mjs +8 -4
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Package the managed backend reproducibly using Go's standard archive writers.
|
|
2
|
+
// Usage: go run scripts/studio-archive.go <output.tar.gz|output.zip> <binary> <LICENSE>
|
|
3
|
+
package main
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"archive/tar"
|
|
7
|
+
"archive/zip"
|
|
8
|
+
"compress/gzip"
|
|
9
|
+
"fmt"
|
|
10
|
+
"os"
|
|
11
|
+
"path/filepath"
|
|
12
|
+
"strings"
|
|
13
|
+
"time"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
func main() {
|
|
17
|
+
if err := pack(); err != nil {
|
|
18
|
+
fmt.Fprintln(os.Stderr, err)
|
|
19
|
+
os.Exit(1)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func pack() error {
|
|
24
|
+
if len(os.Args) != 4 {
|
|
25
|
+
return fmt.Errorf("usage: studio-archive <archive> <binary> <LICENSE>")
|
|
26
|
+
}
|
|
27
|
+
binary, err := os.ReadFile(os.Args[2])
|
|
28
|
+
if err != nil {
|
|
29
|
+
return err
|
|
30
|
+
}
|
|
31
|
+
license, err := os.ReadFile(os.Args[3])
|
|
32
|
+
if err != nil {
|
|
33
|
+
return err
|
|
34
|
+
}
|
|
35
|
+
entries := []struct {
|
|
36
|
+
name string
|
|
37
|
+
data []byte
|
|
38
|
+
mode int64
|
|
39
|
+
}{
|
|
40
|
+
{"LICENSE", license, 0644},
|
|
41
|
+
{"README-MAGPIE.txt", []byte("AIStudio2API, managed by magpie-gemini-web.\nOnline updates are disabled. Upgrade through the Magpie plugin.\nSource and build instructions: https://github.com/BUKOWSKIREAL/magpie-gemini-web\n"), 0644},
|
|
42
|
+
{filepath.Base(os.Args[2]), binary, 0755},
|
|
43
|
+
}
|
|
44
|
+
file, err := os.Create(os.Args[1])
|
|
45
|
+
if err != nil {
|
|
46
|
+
return err
|
|
47
|
+
}
|
|
48
|
+
defer file.Close()
|
|
49
|
+
if strings.HasSuffix(os.Args[1], ".zip") {
|
|
50
|
+
writer := zip.NewWriter(file)
|
|
51
|
+
for _, entry := range entries {
|
|
52
|
+
header := &zip.FileHeader{Name: entry.name, Method: zip.Deflate}
|
|
53
|
+
header.SetModTime(time.Date(1980, 1, 1, 0, 0, 0, 0, time.UTC))
|
|
54
|
+
header.SetMode(os.FileMode(entry.mode))
|
|
55
|
+
out, err := writer.CreateHeader(header)
|
|
56
|
+
if err != nil {
|
|
57
|
+
return err
|
|
58
|
+
}
|
|
59
|
+
if _, err = out.Write(entry.data); err != nil {
|
|
60
|
+
return err
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if err := writer.Close(); err != nil {
|
|
64
|
+
return err
|
|
65
|
+
}
|
|
66
|
+
return file.Close()
|
|
67
|
+
}
|
|
68
|
+
if !strings.HasSuffix(os.Args[1], ".tar.gz") {
|
|
69
|
+
return fmt.Errorf("unsupported archive extension")
|
|
70
|
+
}
|
|
71
|
+
compressed, err := gzip.NewWriterLevel(file, gzip.BestCompression)
|
|
72
|
+
if err != nil {
|
|
73
|
+
return err
|
|
74
|
+
}
|
|
75
|
+
writer := tar.NewWriter(compressed)
|
|
76
|
+
for _, entry := range entries {
|
|
77
|
+
header := &tar.Header{Name: entry.name, Mode: entry.mode, Size: int64(len(entry.data)), ModTime: time.Unix(0, 0), Format: tar.FormatUSTAR}
|
|
78
|
+
if err := writer.WriteHeader(header); err != nil {
|
|
79
|
+
return err
|
|
80
|
+
}
|
|
81
|
+
if _, err := writer.Write(entry.data); err != nil {
|
|
82
|
+
return err
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if err := writer.Close(); err != nil {
|
|
86
|
+
return err
|
|
87
|
+
}
|
|
88
|
+
if err := compressed.Close(); err != nil {
|
|
89
|
+
return err
|
|
90
|
+
}
|
|
91
|
+
return file.Close()
|
|
92
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Isolated real-binary check: no Google accounts, no generation or online update.
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { installStudio, ensureStudio, stopStudio, studioAdmin, studioStatus, computeFileHash, STUDIO_VERSION } from '../lib/studio-native.mjs';
|
|
8
|
+
import release from '../lib/studio-release.json' with { type: 'json' };
|
|
9
|
+
|
|
10
|
+
const root = fileURLToPath(new URL('..', import.meta.url));
|
|
11
|
+
const key = `${process.platform}-${process.arch}`;
|
|
12
|
+
const pin = release.binaries[key];
|
|
13
|
+
assert.ok(pin, `Unsupported platform ${key}`);
|
|
14
|
+
const archivePath = path.join(root, 'studio-build/release', pin.asset);
|
|
15
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'magpie-studio-managed-smoke-'));
|
|
16
|
+
let local;
|
|
17
|
+
try {
|
|
18
|
+
local = await installStudio({ dataDir, archivePath });
|
|
19
|
+
const bin = path.join(dataDir, 'package', process.platform === 'win32' ? 'aistudio2api.exe' : 'aistudio2api');
|
|
20
|
+
assert.equal(computeFileHash(bin), pin.binarySha256);
|
|
21
|
+
await ensureStudio({ dataDir, managementOnly: true });
|
|
22
|
+
const pidBefore = fs.readFileSync(path.join(dataDir, 'backend.pid'), 'utf8');
|
|
23
|
+
const status = await studioAdmin(local, 'GET', '/api/status');
|
|
24
|
+
assert.equal(status.version, STUDIO_VERSION);
|
|
25
|
+
for (const query of ['', '?refresh=1']) {
|
|
26
|
+
const update = await studioAdmin(local, 'GET', `/api/update${query}`);
|
|
27
|
+
assert.equal(update.mode, 'managed');
|
|
28
|
+
assert.equal(update.available, false);
|
|
29
|
+
assert.equal(update.updating, false);
|
|
30
|
+
assert.equal(update.current, STUDIO_VERSION);
|
|
31
|
+
}
|
|
32
|
+
let updateHTTP;
|
|
33
|
+
await assert.rejects(studioAdmin(local, 'POST', '/api/update', null, {
|
|
34
|
+
fetchFn: async (...args) => {
|
|
35
|
+
const response = await fetch(...args);
|
|
36
|
+
if (String(args[0]).endsWith('/api/update')) updateHTTP = response.status;
|
|
37
|
+
return response;
|
|
38
|
+
}
|
|
39
|
+
}), /Magpie/);
|
|
40
|
+
assert.equal(updateHTTP, 403);
|
|
41
|
+
assert.equal(computeFileHash(bin), pin.binarySha256, 'update must not replace the binary');
|
|
42
|
+
assert.equal(fs.readFileSync(path.join(dataDir, 'backend.pid'), 'utf8'), pidBefore, 'update must not restart');
|
|
43
|
+
const html = await (await fetch(local.origin)).text();
|
|
44
|
+
const entry = /src="([^"]+\.js)"/.exec(html)?.[1];
|
|
45
|
+
assert.ok(entry, 'admin page must load');
|
|
46
|
+
const js = await (await fetch(new URL(entry, local.origin))).text();
|
|
47
|
+
const versionPanel = /(?:\.\/|\/assets\/)?(VersionPanel-[\w-]+\.js)/.exec(js)?.[1];
|
|
48
|
+
assert.ok(versionPanel, 'built admin version panel must exist');
|
|
49
|
+
const panel = await (await fetch(`${local.origin}/assets/${versionPanel}`)).text();
|
|
50
|
+
assert.match(panel, /Online updates are disabled/);
|
|
51
|
+
assert.ok(!panel.includes('applyUpdate'), 'version panel must not offer online installation');
|
|
52
|
+
assert.deepEqual(await stopStudio({ dataDir }), { stopped: true });
|
|
53
|
+
assert.equal((await studioStatus({ dataDir })).status, 'stopped');
|
|
54
|
+
console.log(`PASS ${key}: ${STUDIO_VERSION}, managed status, update HTTP 403, unchanged executable/PID, managed UI, clean stop`);
|
|
55
|
+
} finally {
|
|
56
|
+
let stopped = !local;
|
|
57
|
+
if (local) stopped = (await stopStudio({ dataDir }).catch(() => ({ stopped: false }))).stopped;
|
|
58
|
+
if (stopped) {
|
|
59
|
+
const pkg = path.join(dataDir, 'package');
|
|
60
|
+
if (fs.existsSync(pkg)) fs.chmodSync(pkg, 0o755);
|
|
61
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
62
|
+
} else {
|
|
63
|
+
console.error(`Could not confirm backend exit; preserved ${dataDir}`);
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Clean upstream commit + reviewed patch -> managed archives, never a developer's dirty tree.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const root = fileURLToPath(new URL('..', import.meta.url));
|
|
11
|
+
const pinPath = path.join(root, 'lib/studio-release.json');
|
|
12
|
+
const release = JSON.parse(fs.readFileSync(pinPath, 'utf8'));
|
|
13
|
+
const args = process.argv.slice(2);
|
|
14
|
+
for (const arg of args) {
|
|
15
|
+
if (!['--write-pins', '--verify'].includes(arg) && !arg.startsWith('--target=') && !arg.startsWith('--source=')) throw new Error(`Unknown argument: ${arg}`);
|
|
16
|
+
}
|
|
17
|
+
const writePins = args.includes('--write-pins');
|
|
18
|
+
const verify = args.includes('--verify');
|
|
19
|
+
const requested = args.find(arg => arg.startsWith('--target='))?.slice(9);
|
|
20
|
+
const target = requested === 'host' ? `${process.platform}-${process.arch}` : requested;
|
|
21
|
+
const platforms = { 'darwin-arm64': ['darwin', 'arm64'], 'darwin-x64': ['darwin', 'amd64'],
|
|
22
|
+
'linux-x64': ['linux', 'amd64'], 'linux-arm64': ['linux', 'arm64'], 'win32-x64': ['windows', 'amd64'] };
|
|
23
|
+
if (target && !platforms[target]) throw new Error(`Unsupported target: ${target}`);
|
|
24
|
+
if (writePins && (target || verify)) throw new Error('--write-pins requires all targets without --verify');
|
|
25
|
+
const output = path.join(root, 'studio-build/release');
|
|
26
|
+
fs.mkdirSync(output, { recursive: true });
|
|
27
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'magpie-studio-build-'));
|
|
28
|
+
const source = path.join(scratch, 'source');
|
|
29
|
+
fs.mkdirSync(source);
|
|
30
|
+
const env = { ...process.env, CGO_ENABLED: '0', GOFLAGS: '', GOEXPERIMENT: '',
|
|
31
|
+
GOTOOLCHAIN: `go${release.goVersion}`, GOAMD64: 'v1', GOARM64: 'v8.0' };
|
|
32
|
+
for (const key of ['GOOS', 'GOARCH']) delete env[key];
|
|
33
|
+
const hash = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
34
|
+
const go = (argv, extra = {}) => execFileSync('go', argv, { cwd: source, env: { ...env, ...extra }, stdio: 'inherit' });
|
|
35
|
+
const npm = argv => execFileSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', argv,
|
|
36
|
+
{ cwd: path.join(source, 'web'), stdio: 'inherit', shell: process.platform === 'win32' });
|
|
37
|
+
const patch = path.join(root, 'patches/studio-managed.patch');
|
|
38
|
+
const flags = ['-mod=readonly', '-trimpath', '-buildvcs=false',
|
|
39
|
+
`-ldflags=-s -w -X github.com/Mag1cFall/AIStudio2API/internal/app.managedVersion=v${release.version}`];
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
let upstream = args.find(arg => arg.startsWith('--source='))?.slice(9);
|
|
43
|
+
if (upstream) {
|
|
44
|
+
upstream = path.resolve(upstream);
|
|
45
|
+
execFileSync('git', ['-C', upstream, 'cat-file', '-e', `${release.upstream.commit}^{commit}`]);
|
|
46
|
+
} else {
|
|
47
|
+
upstream = path.join(scratch, 'upstream');
|
|
48
|
+
execFileSync('git', ['init', '-q', upstream]);
|
|
49
|
+
execFileSync('git', ['-C', upstream, 'fetch', '--depth=1', release.upstream.repository, release.upstream.commit], { stdio: 'inherit' });
|
|
50
|
+
}
|
|
51
|
+
execFileSync('git', ['-C', upstream, 'archive', '--format=tar', `--output=${path.join(scratch, 'upstream.tar')}`, release.upstream.commit]);
|
|
52
|
+
execFileSync('tar', ['-xf', 'upstream.tar', '-C', 'source'], { cwd: scratch });
|
|
53
|
+
execFileSync('git', ['apply', '--check', patch], { cwd: source, stdio: 'inherit' });
|
|
54
|
+
execFileSync('git', ['apply', patch], { cwd: source, stdio: 'inherit' });
|
|
55
|
+
npm(['ci', '--no-audit', '--no-fund']);
|
|
56
|
+
npm(['run', 'lint']);
|
|
57
|
+
npm(['run', 'build']);
|
|
58
|
+
go(['test', '-mod=readonly', './cmd/...', './internal/...', '-count=1']);
|
|
59
|
+
const binaries = {};
|
|
60
|
+
for (const key of target ? [target] : Object.keys(platforms)) {
|
|
61
|
+
const [goos, goarch] = platforms[key];
|
|
62
|
+
const binary = path.join(scratch, goos === 'windows' ? 'aistudio2api.exe' : 'aistudio2api');
|
|
63
|
+
const asset = `aistudio2api-v${release.version}-${goos}-${goarch}.${goos === 'windows' ? 'zip' : 'tar.gz'}`;
|
|
64
|
+
console.log(`Building ${key}: ${asset}`);
|
|
65
|
+
go(['build', ...flags, '-o', binary, './cmd/aistudio2api'], { GOOS: goos, GOARCH: goarch });
|
|
66
|
+
go(['run', path.join(root, 'scripts/studio-archive.go'), path.join(output, asset), binary, path.join(source, 'LICENSE')]);
|
|
67
|
+
const sha256 = hash(path.join(output, asset));
|
|
68
|
+
const binarySha256 = hash(binary);
|
|
69
|
+
if (verify && (release.binaries[key]?.sha256 !== sha256 || release.binaries[key]?.binarySha256 !== binarySha256 || release.binaries[key]?.asset !== asset)) {
|
|
70
|
+
throw new Error(`Release pin mismatch for ${key}: ${sha256}`);
|
|
71
|
+
}
|
|
72
|
+
binaries[key] = { asset, sha256, binarySha256 };
|
|
73
|
+
}
|
|
74
|
+
fs.copyFileSync(path.join(source, 'LICENSE'), path.join(output, 'LICENSE-AISTUDIO'));
|
|
75
|
+
fs.writeFileSync(path.join(output, target ? `build-${target}.json` : 'build.json'), JSON.stringify({
|
|
76
|
+
version: release.version, upstream: release.upstream, goVersion: release.goVersion,
|
|
77
|
+
nodeVersion: process.version, patchSha256: hash(patch), flags, binaries
|
|
78
|
+
}, null, 2) + '\n');
|
|
79
|
+
fs.writeFileSync(path.join(output, target ? `SHA256SUMS-${target}.txt` : 'SHA256SUMS.txt'),
|
|
80
|
+
Object.values(binaries).map(pin => `${pin.sha256} ${pin.asset}\n`).join(''));
|
|
81
|
+
if (writePins) fs.writeFileSync(pinPath, JSON.stringify({ ...release, binaries }, null, 2) + '\n');
|
|
82
|
+
console.log(`Managed Studio archives ready: ${output}`);
|
|
83
|
+
} finally {
|
|
84
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
85
|
+
}
|
package/scripts/studio.mjs
CHANGED
|
@@ -124,6 +124,7 @@ async function main() {
|
|
|
124
124
|
switch (command) {
|
|
125
125
|
case 'install': {
|
|
126
126
|
const status = await studioStatus({ dataDir });
|
|
127
|
+
if (status.status === 'version_conflict') throw new Error(status.reason);
|
|
127
128
|
if (status.status === 'running' || status.status === 'outdated' || status.status === 'unresponsive') {
|
|
128
129
|
throw new Error('Cannot install or update AI Studio while service is running. Stop the service first.');
|
|
129
130
|
}
|
|
@@ -143,6 +144,7 @@ async function main() {
|
|
|
143
144
|
}
|
|
144
145
|
case 'start': {
|
|
145
146
|
const status = await studioStatus({ dataDir });
|
|
147
|
+
if (status.status === 'version_conflict') throw new Error(status.reason);
|
|
146
148
|
if (status.status === 'running') {
|
|
147
149
|
if (flags.proxy !== undefined) {
|
|
148
150
|
throw new Error('Cannot update proxy while service is running. Stop the service first.');
|
|
@@ -180,13 +182,15 @@ async function main() {
|
|
|
180
182
|
console.log(` - ${a.label || a.id} (${a.state || 'active'})`);
|
|
181
183
|
}
|
|
182
184
|
}
|
|
185
|
+
} else if (status.status === 'version_conflict') {
|
|
186
|
+
console.log(status.reason);
|
|
183
187
|
} else if (status.status === 'outdated') {
|
|
184
188
|
console.log(`Running version: ${status.version} from an earlier install (this plugin uses ${status.expected})`);
|
|
185
|
-
console.log(`To replace it, run: ${studioCommand('start')}`);
|
|
186
|
-
console.log(`To stop it, run: ${studioCommand('stop')}`);
|
|
189
|
+
console.log(`To replace it, run: ${studioCommand('start', { dataDir })}`);
|
|
190
|
+
console.log(`To stop it, run: ${studioCommand('stop', { dataDir })}`);
|
|
187
191
|
} else if (status.status === 'unresponsive') {
|
|
188
192
|
console.log(`Port ${status.port} answers but not properly (${status.reason})`);
|
|
189
|
-
console.log(`If the service is stuck, run: ${studioCommand('stop')}`);
|
|
193
|
+
console.log(`If the service is stuck, run: ${studioCommand('stop', { dataDir })}`);
|
|
190
194
|
} else if (status.status === 'foreign') {
|
|
191
195
|
console.log(`Warning: Foreign listener on port ${status.port} (${status.reason})`);
|
|
192
196
|
} else {
|
|
@@ -228,7 +232,7 @@ async function main() {
|
|
|
228
232
|
const names = expired.map(a => a.label || a.id).join(', ');
|
|
229
233
|
throw new Error(wanted
|
|
230
234
|
? `${wanted} doesn't need to sign in again; these do: ${names}`
|
|
231
|
-
: `Several accounts need to sign in again; name one: ${studioCommand('relogin <account>')} (${names})`);
|
|
235
|
+
: `Several accounts need to sign in again; name one: ${studioCommand('relogin <account>', { dataDir })} (${names})`);
|
|
232
236
|
}
|
|
233
237
|
console.log(`Opening isolated browser window to sign ${chosen.label || chosen.id} in again...`);
|
|
234
238
|
const res = await studioAdmin(local, 'POST', `/api/accounts/${encodeURIComponent(chosen.id)}/login`, {}, { timeoutMs: LOGIN_TIMEOUT_MS });
|