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.
@@ -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
+ }
@@ -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 });