@cityholdings/minicube-dev 0.0.0-stage → 0.1.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/src/config.js ADDED
@@ -0,0 +1,126 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { CliError } from './log.js';
5
+
6
+ /**
7
+ * Global configuration. The installed package ships no endpoints, environments or credentials:
8
+ * everything comes from this file (or MINICUBE_DEV_CONFIG / --config).
9
+ */
10
+ export function globalConfigPath(flags = {}) {
11
+ if (flags.config) return path.resolve(String(flags.config));
12
+ if (process.env.MINICUBE_DEV_CONFIG) return path.resolve(process.env.MINICUBE_DEV_CONFIG);
13
+ return path.join(os.homedir(), '.minicube-dev', 'config.json');
14
+ }
15
+
16
+ export function loadGlobalConfig(flags = {}) {
17
+ const file = globalConfigPath(flags);
18
+ if (!existsSync(file)) {
19
+ return { file, exists: false, data: { environments: {} } };
20
+ }
21
+ let data;
22
+ try {
23
+ data = JSON.parse(readFileSync(file, 'utf8'));
24
+ } catch (error) {
25
+ throw new CliError(`Cannot parse ${file}: ${error.message}`);
26
+ }
27
+ data.environments ??= {};
28
+ return { file, exists: true, data };
29
+ }
30
+
31
+ export function saveGlobalConfig(config) {
32
+ mkdirSync(path.dirname(config.file), { recursive: true });
33
+ writeFileSync(config.file, `${JSON.stringify(config.data, null, 2)}\n`, { mode: 0o600 });
34
+ try {
35
+ chmodSync(config.file, 0o600);
36
+ } catch {
37
+ // Windows ignores POSIX modes; the file lives in the user's profile directory.
38
+ }
39
+ }
40
+
41
+ export const CONFIG_TEMPLATE = {
42
+ defaultEnv: 'tst',
43
+ environments: {
44
+ tst: {
45
+ description: 'Test environment',
46
+ baseUrl: 'https://<tst-gateway-host>/core',
47
+ token: '<issued by: minicube-dev admin token create --env tst>',
48
+ previewScheme: 'citysuper',
49
+ kube: { context: '<kubectl context>', namespace: '<namespace>', service: '<mpaas-core service>', port: 8301 },
50
+ },
51
+ },
52
+ devices: {
53
+ android: {
54
+ package: '<android application id>',
55
+ cubeConnectActivity: '<debug activity that accepts cubeActHost/cubeActPort>',
56
+ },
57
+ ios: {
58
+ bundleId: '<ios bundle id>',
59
+ },
60
+ },
61
+ builders: {
62
+ mini: '<path to mini-pkg-builder executable or its runtime index.js>',
63
+ },
64
+ };
65
+
66
+ /**
67
+ * Resolve the environment to use: --env, then project defaultEnv, then global defaultEnv.
68
+ * Tokens can be inline (`token`) or read from an environment variable (`tokenEnv`).
69
+ */
70
+ export function resolveEnv(globalConfig, flags = {}, project = null) {
71
+ const environments = globalConfig.data.environments ?? {};
72
+ const name = flags.env ?? project?.data?.env ?? globalConfig.data.defaultEnv;
73
+ if (!name) {
74
+ throw new CliError('No environment selected', `Pass --env <name> or set defaultEnv in ${globalConfig.file}`);
75
+ }
76
+ const env = environments[name];
77
+ if (!env) {
78
+ const known = Object.keys(environments);
79
+ throw new CliError(
80
+ `Environment "${name}" is not configured in ${globalConfig.file}`,
81
+ known.length ? `Known environments: ${known.join(', ')}` : 'Run `minicube-dev config init` and fill in your environments.',
82
+ );
83
+ }
84
+ const token = env.tokenEnv ? process.env[env.tokenEnv] : env.token;
85
+ return { name, ...env, token: token && !String(token).startsWith('<') ? token : undefined };
86
+ }
87
+
88
+ export const PROJECT_FILE = 'minicube.json';
89
+
90
+ /** Project configuration (minicube.json) found in or above `start`. */
91
+ export function loadProject(start = process.cwd(), { required = true } = {}) {
92
+ let dir = path.resolve(start);
93
+ for (;;) {
94
+ const file = path.join(dir, PROJECT_FILE);
95
+ if (existsSync(file)) {
96
+ let data;
97
+ try {
98
+ data = JSON.parse(readFileSync(file, 'utf8'));
99
+ } catch (error) {
100
+ throw new CliError(`Cannot parse ${file}: ${error.message}`);
101
+ }
102
+ validateProject(data, file);
103
+ return { file, root: dir, data };
104
+ }
105
+ const parent = path.dirname(dir);
106
+ if (parent === dir) break;
107
+ dir = parent;
108
+ }
109
+ if (required) {
110
+ throw new CliError(`No ${PROJECT_FILE} found in ${path.resolve(start)} or its parents`, 'Run `minicube-dev init` in the project directory.');
111
+ }
112
+ return null;
113
+ }
114
+
115
+ export function validateProject(data, file) {
116
+ if (!['mini', 'h5', 'card'].includes(data.type)) {
117
+ throw new CliError(`${file}: "type" must be one of mini, h5, card`);
118
+ }
119
+ if (!data.resourceId || typeof data.resourceId !== 'string') {
120
+ throw new CliError(`${file}: "resourceId" is required (mini program ID, H5 offline package ID or card ID)`);
121
+ }
122
+ }
123
+
124
+ export function saveProject(project) {
125
+ writeFileSync(project.file, `${JSON.stringify(project.data, null, 2)}\n`);
126
+ }
package/src/device.js ADDED
@@ -0,0 +1,73 @@
1
+ import { CliError, log } from './log.js';
2
+ import { runSync, which } from './proc.js';
3
+
4
+ /**
5
+ * Device targets:
6
+ * android the only connected adb device (or --serial / devices.android.serial)
7
+ * android:<serial> a specific adb device or emulator
8
+ * ios-sim the booted iOS simulator
9
+ * ios-sim:<udid> a specific simulator
10
+ * ios:<udid> a physical iPhone (Xcode 15+ devicectl)
11
+ */
12
+ export function parseTarget(value, devices = {}) {
13
+ const raw = String(value ?? devices.default ?? '');
14
+ const [kind, id] = raw.split(/:(.*)/s);
15
+ if (kind === 'android') return { kind, id: id || devices.android?.serial };
16
+ if (kind === 'ios-sim' || kind === 'simulator') return { kind: 'ios-sim', id: id || 'booted' };
17
+ if (kind === 'ios') {
18
+ if (!id) throw new CliError('ios needs a device id: --device ios:<udid>', 'List devices with `xcrun devicectl list devices`, or use --device ios-sim for the simulator.');
19
+ return { kind, id };
20
+ }
21
+ throw new CliError(`Unknown device "${raw}"`, 'Use android, android:<serial>, ios-sim, ios-sim:<udid> or ios:<udid>.');
22
+ }
23
+
24
+ function adbPath() {
25
+ const adb = process.env.ADB_BINARY ?? which('adb');
26
+ if (!adb) throw new CliError('adb not found', 'Install Android platform-tools and add them to PATH (or set ADB_BINARY).');
27
+ return adb;
28
+ }
29
+
30
+ export function androidDevices() {
31
+ const result = runSync(adbPath(), ['devices']);
32
+ return result.stdout.split(/\r?\n/).slice(1).map((l) => l.trim().split(/\s+/)).filter(([s, state]) => s && state === 'device').map(([s]) => s);
33
+ }
34
+
35
+ export function androidSerial(target) {
36
+ if (target.id) return target.id;
37
+ const devices = androidDevices();
38
+ if (devices.length === 0) throw new CliError('No Android device or emulator is connected', 'Check `adb devices`.');
39
+ if (devices.length > 1) throw new CliError(`${devices.length} Android devices are connected`, `Pick one: --device android:<serial> (${devices.join(', ')})`);
40
+ return devices[0];
41
+ }
42
+
43
+ export function adb(serial, args) {
44
+ const result = runSync(adbPath(), ['-s', serial, ...args]);
45
+ if (result.code !== 0) throw new CliError(`adb ${args.join(' ')} failed: ${(result.stderr || result.stdout).trim()}`);
46
+ return result.stdout;
47
+ }
48
+
49
+ /** Open a citysuper:// (or other app scheme) URL in the host app on the target device. */
50
+ export function openUrl(target, url, devices = {}) {
51
+ if (target.kind === 'android') {
52
+ const serial = androidSerial(target);
53
+ const pkg = devices.android?.package;
54
+ // The remote shell splits on whitespace and interprets &, so quote the URL for it.
55
+ const args = ['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', `'${url}'`];
56
+ if (pkg && !pkg.includes('<')) args.push(pkg);
57
+ const out = adb(serial, args);
58
+ if (/Error:/i.test(out)) throw new CliError(`Android refused the URL: ${out.trim()}`);
59
+ log.success(`Opened on Android ${serial}`);
60
+ return;
61
+ }
62
+ if (target.kind === 'ios-sim') {
63
+ const result = runSync('xcrun', ['simctl', 'openurl', target.id, url]);
64
+ if (result.code !== 0) throw new CliError(`simctl openurl failed: ${result.stderr.trim()}`, 'Is a simulator booted with the app installed? `xcrun simctl list devices booted`');
65
+ log.success(`Opened on iOS simulator ${target.id}`);
66
+ return;
67
+ }
68
+ const bundleId = devices.ios?.bundleId;
69
+ if (!bundleId || bundleId.includes('<')) throw new CliError('devices.ios.bundleId is not configured');
70
+ const result = runSync('xcrun', ['devicectl', 'device', 'process', 'launch', '--device', target.id, '--terminate-existing', '--payload-url', url, bundleId]);
71
+ if (result.code !== 0) throw new CliError(`devicectl launch failed: ${(result.stderr || result.stdout).trim()}`);
72
+ log.success(`Opened on iPhone ${target.id}`);
73
+ }
package/src/kube.js ADDED
@@ -0,0 +1,54 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createServer } from 'node:net';
3
+ import { CliError, log } from './log.js';
4
+ import { which } from './proc.js';
5
+
6
+ function freePort() {
7
+ return new Promise((resolve, reject) => {
8
+ const server = createServer();
9
+ server.once('error', reject);
10
+ server.listen(0, '127.0.0.1', () => {
11
+ const { port } = server.address();
12
+ server.close(() => resolve(port));
13
+ });
14
+ });
15
+ }
16
+
17
+ /**
18
+ * `kubectl port-forward svc/<service> <local>:<port>` using the local kubeconfig.
19
+ * Requests through the tunnel reach the pod on its loopback interface, which is what the
20
+ * token administration endpoints require.
21
+ */
22
+ export async function openPortForward(kube) {
23
+ if (!kube?.service || !kube?.namespace) {
24
+ throw new CliError('This environment has no "kube" section (context, namespace, service, port)', 'Add it to the environment in your minicube-dev config.');
25
+ }
26
+ const kubectl = which(kube.kubectl ?? 'kubectl');
27
+ if (!kubectl) throw new CliError('kubectl not found', 'Install kubectl and configure the kubeconfig for this cluster.');
28
+ const localPort = await freePort();
29
+ const args = [];
30
+ if (kube.kubeconfig) args.push('--kubeconfig', kube.kubeconfig);
31
+ if (kube.context) args.push('--context', kube.context);
32
+ args.push('-n', kube.namespace, 'port-forward', `svc/${kube.service}`, `${localPort}:${kube.port ?? 8301}`, '--address', '127.0.0.1');
33
+ log.detail(`kubectl ${args.join(' ')}`);
34
+ const child = spawn(kubectl, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
35
+ let stderr = '';
36
+ await new Promise((resolve, reject) => {
37
+ const timer = setTimeout(() => reject(new CliError(`kubectl port-forward did not become ready: ${stderr.trim()}`)), 20_000);
38
+ child.stdout.on('data', (data) => {
39
+ if (String(data).includes('Forwarding from')) {
40
+ clearTimeout(timer);
41
+ resolve();
42
+ }
43
+ });
44
+ child.stderr.on('data', (data) => (stderr += data));
45
+ child.once('exit', (code) => {
46
+ clearTimeout(timer);
47
+ reject(new CliError(`kubectl port-forward exited (${code}): ${stderr.trim()}`));
48
+ });
49
+ });
50
+ return {
51
+ baseUrl: `http://127.0.0.1:${localPort}`,
52
+ close: () => child.kill(),
53
+ };
54
+ }
package/src/log.js ADDED
@@ -0,0 +1,56 @@
1
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
2
+ const paint = (code) => (text) => (useColor ? `\u001b[${code}m${text}\u001b[0m` : String(text));
3
+
4
+ export const color = {
5
+ bold: paint(1),
6
+ dim: paint(2),
7
+ red: paint(31),
8
+ green: paint(32),
9
+ yellow: paint(33),
10
+ cyan: paint(36),
11
+ };
12
+
13
+ export class CliError extends Error {
14
+ constructor(message, hint) {
15
+ super(message);
16
+ this.hint = hint;
17
+ }
18
+ }
19
+
20
+ export const log = {
21
+ quiet: false,
22
+ json: false,
23
+ step(message) {
24
+ if (!this.quiet) process.stderr.write(`${color.cyan('▸')} ${message}\n`);
25
+ },
26
+ detail(message) {
27
+ if (!this.quiet) process.stderr.write(` ${color.dim(message)}\n`);
28
+ },
29
+ info(message) {
30
+ process.stderr.write(`${message}\n`);
31
+ },
32
+ warn(message) {
33
+ process.stderr.write(`${color.yellow('!')} ${message}\n`);
34
+ },
35
+ success(message) {
36
+ process.stderr.write(`${color.green('✔')} ${message}\n`);
37
+ },
38
+ /** Machine-readable or primary output goes to stdout. */
39
+ out(value) {
40
+ if (typeof value === 'string') process.stdout.write(`${value}\n`);
41
+ else process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
42
+ },
43
+ };
44
+
45
+ export function printError(error) {
46
+ process.stderr.write(`${color.red('✖')} ${error?.message ?? String(error)}\n`);
47
+ if (error?.hint) process.stderr.write(` ${error.hint}\n`);
48
+ if (process.env.MINICUBE_DEBUG && error?.stack) process.stderr.write(`${error.stack}\n`);
49
+ }
50
+
51
+ export function table(rows, columns) {
52
+ if (rows.length === 0) return '(none)';
53
+ const widths = columns.map((c) => Math.max(c.title.length, ...rows.map((r) => String(r[c.key] ?? '').length)));
54
+ const line = (cells) => cells.map((cell, i) => String(cell ?? '').padEnd(widths[i])).join(' ').trimEnd();
55
+ return [line(columns.map((c) => c.title)), line(widths.map((w) => '-'.repeat(w))), ...rows.map((r) => line(columns.map((c) => r[c.key])))].join('\n');
56
+ }
package/src/proc.js ADDED
@@ -0,0 +1,91 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { existsSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { CliError } from './log.js';
5
+
6
+ const isWindows = process.platform === 'win32';
7
+
8
+ /** Locate an executable on PATH (honours PATHEXT on Windows). */
9
+ export function which(command, extraDirs = []) {
10
+ if (path.isAbsolute(command) || command.includes('/') || command.includes('\\')) {
11
+ return existsSync(command) ? command : null;
12
+ }
13
+ const dirs = [...extraDirs, ...(process.env.PATH ?? '').split(path.delimiter)].filter(Boolean);
14
+ const exts = isWindows ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';').map((e) => e.toLowerCase()) : [''];
15
+ for (const dir of dirs) {
16
+ for (const ext of isWindows ? ['', ...exts] : exts) {
17
+ const candidate = path.join(dir, command + ext);
18
+ try {
19
+ if (statSync(candidate).isFile()) return candidate;
20
+ } catch {
21
+ // keep looking
22
+ }
23
+ }
24
+ }
25
+ return null;
26
+ }
27
+
28
+ /** Windows cannot spawn .cmd/.bat directly; route those through cmd.exe with quoted arguments. */
29
+ function spawnTarget(command, args) {
30
+ if (isWindows && /\.(cmd|bat)$/i.test(command)) {
31
+ const quote = (s) => (/[\s"&|<>^%]/.test(s) ? `"${String(s).replace(/"/g, '""')}"` : s);
32
+ return { file: process.env.ComSpec ?? 'cmd.exe', args: ['/d', '/s', '/c', `"${[command, ...args].map(quote).join(' ')}"`], verbatim: true };
33
+ }
34
+ return { file: command, args, verbatim: false };
35
+ }
36
+
37
+ function resolveCommand(command) {
38
+ const resolved = which(command);
39
+ if (!resolved) throw new CliError(`Command not found: ${command}`, `Install it or add it to PATH.`);
40
+ return resolved;
41
+ }
42
+
43
+ /** Run a command synchronously and capture its output. */
44
+ export function runSync(command, args, options = {}) {
45
+ const target = spawnTarget(resolveCommand(command), args);
46
+ const result = spawnSync(target.file, target.args, {
47
+ encoding: 'utf8',
48
+ windowsVerbatimArguments: target.verbatim,
49
+ maxBuffer: 64 * 1024 * 1024,
50
+ ...options,
51
+ });
52
+ if (result.error) throw new CliError(`Cannot run ${command}: ${result.error.message}`);
53
+ return { code: result.status ?? 1, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
54
+ }
55
+
56
+ /** Run a command, streaming its output (or capturing it with `capture: true`). */
57
+ export function run(command, args, { cwd, env, capture = false, input } = {}) {
58
+ const target = spawnTarget(resolveCommand(command), args);
59
+ return new Promise((resolve, reject) => {
60
+ const child = spawn(target.file, target.args, {
61
+ cwd,
62
+ env: env ? { ...process.env, ...env } : process.env,
63
+ stdio: [input === undefined ? 'inherit' : 'pipe', capture ? 'pipe' : 'inherit', capture ? 'pipe' : 'inherit'],
64
+ windowsVerbatimArguments: target.verbatim,
65
+ });
66
+ let stdout = '';
67
+ let stderr = '';
68
+ child.stdout?.on('data', (d) => (stdout += d));
69
+ child.stderr?.on('data', (d) => (stderr += d));
70
+ if (input !== undefined) child.stdin.end(input);
71
+ child.once('error', (error) => reject(new CliError(`Cannot run ${command}: ${error.message}`)));
72
+ child.once('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
73
+ });
74
+ }
75
+
76
+ /** Start a long-lived background process detached from this one. */
77
+ export function spawnDetached(command, args, { cwd, logFd, env }) {
78
+ const target = spawnTarget(resolveCommand(command), args);
79
+ const child = spawn(target.file, target.args, {
80
+ cwd,
81
+ env: env ? { ...process.env, ...env } : process.env,
82
+ detached: true,
83
+ stdio: ['ignore', logFd, logFd],
84
+ windowsVerbatimArguments: target.verbatim,
85
+ windowsHide: true,
86
+ });
87
+ child.unref();
88
+ return child;
89
+ }
90
+
91
+ export const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -0,0 +1,113 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { list, one } from './args.js';
4
+ import { CliError, log } from './log.js';
5
+
6
+ const VERSION = /^\d+(\.\d+){2,3}$/;
7
+
8
+ export function compareVersions(a, b) {
9
+ const pa = a.split('.').map(Number);
10
+ const pb = b.split('.').map(Number);
11
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
12
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
13
+ if (d) return Math.sign(d);
14
+ }
15
+ return 0;
16
+ }
17
+
18
+ /** Highest existing version with its last segment incremented; `fallback` when nothing exists yet. */
19
+ export function nextVersion(existing, fallback = '1.0.0') {
20
+ const versions = existing.filter((v) => VERSION.test(v ?? ''));
21
+ if (versions.length === 0) return fallback;
22
+ const max = versions.sort(compareVersions).at(-1);
23
+ const parts = max.split('.').map(Number);
24
+ parts[parts.length - 1] += 1;
25
+ return parts.join('.');
26
+ }
27
+
28
+ /**
29
+ * Version to upload: --version, then minicube.json, then (cards) manifest.json; when that one is
30
+ * already taken (or nothing is set) the next free version after the highest existing one.
31
+ */
32
+ export function chooseVersion(flags, project, packages) {
33
+ const explicit = one(flags.version);
34
+ const existing = packages.map((p) => p.version);
35
+ if (explicit) {
36
+ if (!VERSION.test(explicit)) throw new CliError(`Invalid version ${explicit}`, 'Use 1.0.0 or 1.0.0.0');
37
+ if (existing.includes(explicit)) throw new CliError(`Version ${explicit} of ${project.data.resourceId} already exists on the server`);
38
+ return explicit;
39
+ }
40
+ let configured = project.data.version;
41
+ if (!configured && project.data.type === 'card') {
42
+ const manifest = path.join(path.resolve(project.root, project.data.card?.dir ?? '.'), 'manifest.json');
43
+ if (existsSync(manifest)) configured = JSON.parse(readFileSync(manifest, 'utf8')).version;
44
+ }
45
+ if (configured && VERSION.test(configured) && !existing.includes(configured) && existing.every((v) => compareVersions(configured, v) > 0)) {
46
+ return configured;
47
+ }
48
+ return nextVersion(existing, configured && VERSION.test(configured) ? configured : '1.0.0');
49
+ }
50
+
51
+ /** Device preview URL. Mini programs keep the IDE preview QR layout with the app's own scheme. */
52
+ export function previewUrl(type, resourceId, version, scheme = 'citysuper') {
53
+ if (type === 'mini') {
54
+ const inner = `${scheme}://platformapi/startapp?appId=${resourceId}&nbsv=${version}&nbsource=debug&nbprefer=YES&nbsn=DEBUG&nboffline=sync&nbtoken=ide_qr&chInfo=ch_minicube&channelId=minicube&isRemoteDebug=false&enableBugme=false&xriver=false`;
55
+ return `${scheme}://platformapi/startapp?appId=20001101&token=ide_qr&scheme=${encodeURIComponent(inner)}`;
56
+ }
57
+ if (type === 'h5') {
58
+ return `${scheme}://platformapi/startapp?appId=${resourceId}&nbsv=${version}&nbsource=debug&nbprefer=YES&nbsn=DEBUG&nboffline=sync`;
59
+ }
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * Whitelists for a debug upload, from --whitelist <id|name> (repeatable) or minicube.json
65
+ * debug.whitelist. `--whitelist-create <name> --items a,b` creates a new one first.
66
+ */
67
+ export async function resolveWhitelistIds(api, flags, project) {
68
+ const existing = await api.whitelists();
69
+ const ids = [];
70
+ const createName = one(flags['whitelist-create']);
71
+ if (createName) {
72
+ const items = list(flags.items);
73
+ if (items.length === 0) throw new CliError('--whitelist-create needs --items <userId,...>');
74
+ const created = await api.createWhitelist(createName, items);
75
+ log.detail(`Created whitelist ${created.name} (#${created.id}) with ${created.itemCount} member(s)`);
76
+ ids.push(created.id);
77
+ }
78
+ const wanted = [...list(flags.whitelist), ...(ids.length || list(flags.whitelist).length ? [] : [].concat(project?.data?.debug?.whitelist ?? []).map(String))];
79
+ for (const ref of wanted) {
80
+ const match = existing.find((w) => String(w.id) === ref) ?? existing.find((w) => w.name === ref);
81
+ if (!match) {
82
+ throw new CliError(`Whitelist "${ref}" not found`, `Available: ${existing.map((w) => `${w.id}:${w.name}`).join(', ') || '(none)'} — or create one with --whitelist-create <name> --items <userIds>`);
83
+ }
84
+ ids.push(match.id);
85
+ }
86
+ if (ids.length === 0) {
87
+ throw new CliError('A debug package needs at least one whitelist (only its members can download it)', 'Pass --whitelist <id|name>, set debug.whitelist in minicube.json, or --whitelist-create <name> --items <userIds>.');
88
+ }
89
+ const extraItems = createName ? [] : list(flags.items);
90
+ if (extraItems.length) {
91
+ await api.addWhitelistItems(ids[0], extraItems);
92
+ log.detail(`Added ${extraItems.length} member(s) to whitelist #${ids[0]}`);
93
+ }
94
+ return [...new Set(ids)];
95
+ }
96
+
97
+ export function uploadMeta(project, { version, channel, mainUrl, whitelistIds, description }) {
98
+ const d = project.data;
99
+ return {
100
+ resourceId: d.resourceId,
101
+ version,
102
+ channel,
103
+ mainUrl,
104
+ whitelistIds,
105
+ clientScopes: d.clientScopes,
106
+ enableTabBar: d.enableTabBar,
107
+ enableOptionMenu: d.enableOptionMenu,
108
+ // The server default expand describes a mini program (enableDSL=YES); iOS would then launch an
109
+ // H5 offline package as a mini program. Console-uploaded H5 packages use empty launch params.
110
+ expand: d.expand ? (typeof d.expand === 'string' ? d.expand : JSON.stringify(d.expand)) : d.type === 'h5' ? '{"launchParams":{}}' : undefined,
111
+ description,
112
+ };
113
+ }
package/src/zip.js ADDED
@@ -0,0 +1,110 @@
1
+ import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { deflateRawSync } from 'node:zlib';
4
+
5
+ const CRC_TABLE = (() => {
6
+ const table = new Uint32Array(256);
7
+ for (let n = 0; n < 256; n++) {
8
+ let c = n;
9
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
10
+ table[n] = c >>> 0;
11
+ }
12
+ return table;
13
+ })();
14
+
15
+ export function crc32(buffer) {
16
+ let crc = 0xffffffff;
17
+ for (let i = 0; i < buffer.length; i++) crc = CRC_TABLE[(crc ^ buffer[i]) & 0xff] ^ (crc >>> 8);
18
+ return (crc ^ 0xffffffff) >>> 0;
19
+ }
20
+
21
+ function dosDateTime(date) {
22
+ const time = (date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >> 1);
23
+ const day = ((date.getFullYear() - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate();
24
+ return { time, day };
25
+ }
26
+
27
+ /** Files under `dir`, as forward-slash paths relative to it, sorted for reproducible archives. */
28
+ export function listFiles(dir, base = '') {
29
+ const out = [];
30
+ for (const entry of readdirSync(path.join(dir, base), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
31
+ const rel = base ? `${base}/${entry.name}` : entry.name;
32
+ if (entry.isDirectory()) out.push(...listFiles(dir, rel));
33
+ else if (entry.isFile()) out.push(rel);
34
+ }
35
+ return out;
36
+ }
37
+
38
+ /**
39
+ * Write a ZIP archive. `entries` is a list of { name, data } (name uses forward slashes).
40
+ * Directory entries are emitted for every parent so Java's ZipInputStream recreates them.
41
+ */
42
+ export function writeZip(file, entries) {
43
+ const { time, day } = dosDateTime(new Date());
44
+ const chunks = [];
45
+ const central = [];
46
+ let offset = 0;
47
+ const dirs = new Set();
48
+ for (const { name } of entries) {
49
+ const parts = name.split('/');
50
+ for (let i = 1; i < parts.length; i++) dirs.add(`${parts.slice(0, i).join('/')}/`);
51
+ }
52
+ const all = [...[...dirs].sort().map((name) => ({ name, data: Buffer.alloc(0), dir: true })), ...entries];
53
+ for (const entry of all) {
54
+ const nameBuf = Buffer.from(entry.name, 'utf8');
55
+ const raw = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data);
56
+ const deflated = entry.dir ? raw : deflateRawSync(raw);
57
+ const useDeflate = !entry.dir && deflated.length < raw.length;
58
+ const body = useDeflate ? deflated : raw;
59
+ const crc = crc32(raw);
60
+ const local = Buffer.alloc(30);
61
+ local.writeUInt32LE(0x04034b50, 0);
62
+ local.writeUInt16LE(20, 4);
63
+ local.writeUInt16LE(0x0800, 6); // UTF-8 names
64
+ local.writeUInt16LE(useDeflate ? 8 : 0, 8);
65
+ local.writeUInt16LE(time, 10);
66
+ local.writeUInt16LE(day, 12);
67
+ local.writeUInt32LE(crc, 14);
68
+ local.writeUInt32LE(body.length, 18);
69
+ local.writeUInt32LE(raw.length, 22);
70
+ local.writeUInt16LE(nameBuf.length, 26);
71
+ local.writeUInt16LE(0, 28);
72
+ chunks.push(local, nameBuf, body);
73
+
74
+ const header = Buffer.alloc(46);
75
+ header.writeUInt32LE(0x02014b50, 0);
76
+ header.writeUInt16LE(20, 4);
77
+ header.writeUInt16LE(20, 6);
78
+ header.writeUInt16LE(0x0800, 8);
79
+ header.writeUInt16LE(useDeflate ? 8 : 0, 10);
80
+ header.writeUInt16LE(time, 12);
81
+ header.writeUInt16LE(day, 14);
82
+ header.writeUInt32LE(crc, 16);
83
+ header.writeUInt32LE(body.length, 20);
84
+ header.writeUInt32LE(raw.length, 24);
85
+ header.writeUInt16LE(nameBuf.length, 28);
86
+ header.writeUInt32LE(entry.dir ? 0x10 : 0, 38);
87
+ header.writeUInt32LE(offset, 42);
88
+ central.push(header, nameBuf);
89
+ offset += local.length + nameBuf.length + body.length;
90
+ }
91
+ const centralSize = central.reduce((n, b) => n + b.length, 0);
92
+ const end = Buffer.alloc(22);
93
+ end.writeUInt32LE(0x06054b50, 0);
94
+ end.writeUInt16LE(all.length, 8);
95
+ end.writeUInt16LE(all.length, 10);
96
+ end.writeUInt32LE(centralSize, 12);
97
+ end.writeUInt32LE(offset, 16);
98
+ writeFileSync(file, Buffer.concat([...chunks, ...central, end]));
99
+ return file;
100
+ }
101
+
102
+ /** Zip the contents of `dir` under the archive folder `prefix` ('' for the archive root). */
103
+ export function zipDirectory(dir, file, prefix = '') {
104
+ const entries = listFiles(dir).map((rel) => ({ name: prefix ? `${prefix}/${rel}` : rel, data: readFileSync(path.join(dir, rel)) }));
105
+ return writeZip(file, entries);
106
+ }
107
+
108
+ export function fileSize(file) {
109
+ return statSync(file).size;
110
+ }