@cityholdings/minicube-dev 0.1.4 → 0.1.5

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 CHANGED
@@ -67,7 +67,7 @@ by `MINICUBE_DEV_CONFIG` / `--config`. Create a skeleton with `minicube-dev conf
67
67
  },
68
68
  "devices": {
69
69
  "android": { "package": "<android application id>", "cubeConnectActivity": "<debug activity accepting cubeActHost/cubeActPort>" },
70
- "ios": { "bundleId": "<bundle id, physical devices only>" }
70
+ "ios": { "bundleId": "<host app bundle id>" }
71
71
  }
72
72
  }
73
73
  ```
@@ -143,11 +143,36 @@ minicube-dev debug --whitelist-create qa --items 10001 # new whitelist for this
143
143
 
144
144
  * Mini program / H5 previews open `citysuper://platformapi/startapp?…` links (the mPaaS IDE
145
145
  preview layout with the app scheme instead of `mpaas`). Only non-production builds accept them.
146
+ * On iOS the host app resumes a mini program that is already open and ignores a new link, so the
147
+ new package would not load. minicube-dev therefore quits the app (`devices.ios.bundleId`) before
148
+ opening the link on the simulator and on physical iPhones; the link then cold-starts it.
146
149
  * Card previews attach the app to `act server` without scanning: Android starts the debug-only
147
150
  connect activity (emulators get an `adb reverse` tunnel); the iOS simulator opens
148
151
  `citysuper://cubedebug/connect?host=127.0.0.1&port=9001` (UAT target, which links CubeDev).
149
152
  Physical phones scan the ACT QR code.
150
153
 
154
+ ## Installed versions on a device
155
+
156
+ ```bash
157
+ minicube-dev installed --device ios-sim # alias: versions
158
+ minicube-dev installed --device android --type mini
159
+ minicube-dev installed --device ios:<udid> --id 1000000000000001 --json
160
+ ```
161
+
162
+ Lists every mini program, H5 offline package and Cube card the app has installed, newest version
163
+ first, with older copies still on disk. Mini programs and H5 packages come from each package's
164
+ `Manifest.xml`. Cards come from the Cube cache and show the card's own version together with the
165
+ server resource version. Names come from the server when that environment has a token; cards show
166
+ their template name.
167
+
168
+ - iOS keeps packages per environment. The ENV column is matched to your configured environments by
169
+ `baseUrl` host (`Documents/NebulaEnvironments/<sha256(host)[:16]>`).
170
+ - Android and cards are not stored per environment and are labelled with `--env` / `defaultEnv`.
171
+ - Only debug / development builds can be read: Android uses `run-as`; a USB iPhone uses go-ios
172
+ (`npm i -g go-ios`; iOS 17+ also needs `ios tunnel start --userspace`); the simulator needs nothing.
173
+ - Speed on a USB iPhone: about 45 s on iOS 16, where files are read in parallel. On iOS 17+ the tunnel only copes with
174
+ one file at a time, so it takes about 2–3 minutes; the command prints an estimate first.
175
+
151
176
  ## Release
152
177
 
153
178
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cityholdings/minicube-dev",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Command-line workflow for mPaaS mini programs, H5 offline packages and Cube cards: build, debug upload, device preview and release",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  CONFIG_TEMPLATE, PROJECT_FILE, globalConfigPath, loadGlobalConfig, loadProject, resolveEnv, saveGlobalConfig, saveProject, validateProject,
11
11
  } from './config.js';
12
12
  import { openUrl, parseTarget } from './device.js';
13
+ import { groupInstalled, readInstalled } from './installed.js';
13
14
  import { CliError, color, log, table } from './log.js';
14
15
  import { which } from './proc.js';
15
16
  import { chooseVersion, previewUrl, resolveWhitelistIds, uploadMeta } from './workflow.js';
@@ -33,6 +34,9 @@ Develop
33
34
  --items <userIds> add members to the (first) whitelist
34
35
  --remote card only: upload a whitelist-gray card instead of a local push
35
36
  preview [<url>|dir] --device <target> open a preview link, or the project's latest debug package
37
+ installed --device <target> [--type mini|h5|card] [--id <resourceId>] (alias: versions)
38
+ versions of mini programs, H5 offline packages and cards installed in the app on the
39
+ device (debug / development builds), with names from the server when a token is set
36
40
 
37
41
  Release
38
42
  release [dir] [--version x.y.z] [--publish full|whitelist] [--whitelist <id|name>] [--yes]
@@ -190,6 +194,60 @@ const COMMANDS = {
190
194
  });
191
195
  },
192
196
 
197
+ async installed(ctx) {
198
+ const config = loadGlobalConfig(ctx.flags);
199
+ const devices = config.data.devices ?? {};
200
+ const device = one(ctx.flags.device) ?? devices.default;
201
+ if (!device) throw new CliError('Usage: minicube-dev installed --device <target>', 'Targets: android, android:<serial>, ios-sim, ios-sim:<udid>, ios:<udid>');
202
+ const target = parseTarget(device, devices);
203
+ const environments = config.data.environments ?? {};
204
+ const found = await readInstalled(target, devices, environments);
205
+ const type = one(ctx.flags.type);
206
+ const id = one(ctx.flags.id);
207
+ // Android and cards are not stored per environment; label them with the selected one.
208
+ const selected = ctx.flags.env ? one(ctx.flags.env) : config.data.defaultEnv;
209
+ let rows = groupInstalled(found).map((row) => ({ ...row, env: row.env ?? (row.type === 'card' ? null : selected) }));
210
+ if (ctx.flags.env) rows = rows.filter((row) => !row.env || row.env === selected);
211
+
212
+ // Names (and mini/H5 types on iOS) from the server, best effort: needs that environment's token.
213
+ const known = new Map();
214
+ for (const name of new Set(rows.map((row) => row.env).filter((env) => env && environments[env]))) {
215
+ try {
216
+ const api = await connect(resolveEnv(config, { env: name }));
217
+ try {
218
+ for (const resource of await api.resources()) known.set(`${name}|${resource.resourceId}`, resource);
219
+ } finally {
220
+ api.close();
221
+ }
222
+ } catch (error) {
223
+ log.detail(`No resource names from ${name}: ${error.message}`);
224
+ }
225
+ }
226
+ rows = rows.map((row) => {
227
+ const resource = known.get(`${row.env}|${row.appId}`);
228
+ return { ...row, type: row.type ?? resource?.type ?? 'mini/h5', name: resource?.name ?? '' };
229
+ });
230
+ if (type) rows = rows.filter((row) => row.type === type);
231
+ if (id) rows = rows.filter((row) => row.appId === id);
232
+
233
+ log.detail(`Read from ${found.source}`);
234
+ output(ctx.flags, rows, (r) => (r.length === 0 ? 'Nothing installed matches.' : table(r.map((row) => ({
235
+ env: row.env ?? '-',
236
+ type: row.type,
237
+ appId: row.appId,
238
+ name: row.name,
239
+ installed: row.versions[0]?.label ?? '',
240
+ older: row.versions.slice(1).map((v) => v.label).join(', '),
241
+ })), [
242
+ { key: 'env', title: 'ENV' }, { key: 'type', title: 'TYPE' }, { key: 'appId', title: 'ID' }, { key: 'name', title: 'NAME' },
243
+ { key: 'installed', title: 'NEWEST INSTALLED' }, { key: 'older', title: 'OLDER COPIES' },
244
+ ])));
245
+ },
246
+
247
+ async versions(ctx) {
248
+ return COMMANDS.installed(ctx);
249
+ },
250
+
193
251
  async packages(ctx) {
194
252
  const project = ctx.args[0] ? null : loadProject('.', { required: false });
195
253
  const id = ctx.args[0] ?? project?.data.resourceId;
package/src/device.js CHANGED
@@ -21,7 +21,7 @@ export function parseTarget(value, devices = {}) {
21
21
  throw new CliError(`Unknown device "${raw}"`, 'Use android, android:<serial>, ios-sim, ios-sim:<udid> or ios:<udid>.');
22
22
  }
23
23
 
24
- function adbPath() {
24
+ export function adbPath() {
25
25
  const adb = process.env.ADB_BINARY ?? which('adb');
26
26
  if (!adb) throw new CliError('adb not found', 'Install Android platform-tools and add them to PATH (or set ADB_BINARY).');
27
27
  return adb;
@@ -60,6 +60,15 @@ export function openUrl(target, url, devices = {}) {
60
60
  return;
61
61
  }
62
62
  if (target.kind === 'ios-sim') {
63
+ // An iOS host app that already shows this mini program resumes it and ignores the link, so
64
+ // the new package never loads. Quit the app first (like devicectl --terminate-existing below);
65
+ // the link then cold-starts it.
66
+ const bundleId = devices.ios?.bundleId;
67
+ if (bundleId && !bundleId.includes('<')) {
68
+ if (runSync('xcrun', ['simctl', 'terminate', target.id, bundleId]).code === 0) log.detail(`Restarted ${bundleId} so the new package loads`);
69
+ } else {
70
+ log.warn('devices.ios.bundleId is not set: if the mini program is already open on the simulator, close it first or the new package will not load.');
71
+ }
63
72
  const result = runSync('xcrun', ['simctl', 'openurl', target.id, url]);
64
73
  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
74
  log.success(`Opened on iOS simulator ${target.id}`);
@@ -0,0 +1,228 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { adbPath, androidSerial } from './device.js';
6
+ import { CliError, log } from './log.js';
7
+ import { run, runSync, which } from './proc.js';
8
+
9
+ /**
10
+ * Package versions installed in the host app's sandbox on a device, read straight from the
11
+ * container the way the app stores them:
12
+ * - mini programs and H5 offline packages: one directory per installed version holding the
13
+ * package's Manifest.xml (<uid>, <version>). iOS keeps them per environment under
14
+ * Documents/NebulaEnvironments/<sha256(gateway host)[:16]>/NAMAPP_UNZIP/; Android under
15
+ * files/MriverInstallApps (mini programs) and files/nebulaInstallApps (H5 offline packages).
16
+ * - Cube cards: cubeCrystal/<hash>/<server version a_b_c_d>/<n>.template, whose header names the
17
+ * card and its own version.
18
+ * Reading another app's sandbox needs a debuggable / development-signed build.
19
+ */
20
+ export async function readInstalled(target, devices, environments) {
21
+ const raw = target.kind === 'android' ? readAndroid(target, devices)
22
+ : target.kind === 'ios-sim' ? readSimulator(target, devices)
23
+ : await readIosDevice(target, devices);
24
+ const envByKey = new Map(Object.entries(environments ?? {}).map(([name, env]) => [environmentKey(env.baseUrl), name]));
25
+ const packages = [];
26
+ for (const { file, text } of raw.manifests) {
27
+ const appId = text.match(/<uid>\s*([^<\s]+)\s*<\/uid>/)?.[1];
28
+ const version = text.match(/<version>\s*([^<\s]+)\s*<\/version>/)?.[1];
29
+ if (!appId || !version) continue;
30
+ const parts = file.split('/');
31
+ const envKey = parts[parts.indexOf('NebulaEnvironments') + 1];
32
+ const type = file.includes('MriverInstallApps') ? 'mini' : file.includes('nebulaInstallApps') ? 'h5' : null;
33
+ packages.push({ appId, version, type, env: parts.includes('NebulaEnvironments') ? envByKey.get(envKey) ?? envKey : null });
34
+ }
35
+ const cards = [];
36
+ for (const { file, text } of raw.templates) {
37
+ const parts = file.split('/');
38
+ const serverVersion = parts[parts.length - 2]?.replaceAll('_', '.');
39
+ // The header is JSON with a few binary bytes interleaved; read the fields leniently.
40
+ const name = text.match(/"name"\s*:\s*"([^"]+)"/)?.[1] ?? text.match(/"assetId"\s*:\s*"([^"]+)"/)?.[1];
41
+ const cardVersion = text.match(/"vers[^"]{0,4}"\s*:\s*"([\d.]+)"/)?.[1];
42
+ if (name) cards.push({ appId: name, version: cardVersion ?? serverVersion, serverVersion, type: 'card', env: null });
43
+ }
44
+ return { packages, cards, source: raw.source };
45
+ }
46
+
47
+ /** Group installed versions per resource, newest first. */
48
+ export function groupInstalled({ packages, cards }) {
49
+ const groups = new Map();
50
+ for (const item of [...packages, ...cards]) {
51
+ const key = `${item.env ?? ''}|${item.type ?? ''}|${item.appId}`;
52
+ const group = groups.get(key) ?? { env: item.env, type: item.type, appId: item.appId, versions: [] };
53
+ const label = item.serverVersion ? `${item.version} (server ${item.serverVersion})` : item.version;
54
+ if (!group.versions.some((v) => v.label === label)) group.versions.push({ version: item.version, serverVersion: item.serverVersion, label });
55
+ groups.set(key, group);
56
+ }
57
+ for (const group of groups.values()) {
58
+ group.versions.sort((a, b) => compareVersions(b.serverVersion ?? b.version, a.serverVersion ?? a.version));
59
+ }
60
+ return [...groups.values()].sort((a, b) => `${a.env}|${a.type}|${a.appId}`.localeCompare(`${b.env}|${b.type}|${b.appId}`));
61
+ }
62
+
63
+ export function compareVersions(a, b) {
64
+ const pa = String(a).split('.').map((n) => Number.parseInt(n, 10) || 0);
65
+ const pb = String(b).split('.').map((n) => Number.parseInt(n, 10) || 0);
66
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
67
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
68
+ }
69
+ return 0;
70
+ }
71
+
72
+ /** The iOS app's per-environment storage key: sha256 of the gateway hostname, first 16 hex chars. */
73
+ export function environmentKey(baseUrl) {
74
+ try {
75
+ const host = new URL(baseUrl).hostname.toLowerCase().replace(/\.+$/, '');
76
+ return createHash('sha256').update(host).digest('hex').slice(0, 16);
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ // --- Android: one run-as shell pass (debuggable builds) ---------------------------------------------
83
+
84
+ function readAndroid(target, devices) {
85
+ const serial = androidSerial(target);
86
+ const pkg = devices.android?.package;
87
+ if (!pkg || pkg.includes('<')) throw new CliError('devices.android.package is not configured');
88
+ const script = [
89
+ 'for f in files/MriverInstallApps/*/*/Manifest.xml files/nebulaInstallApps/*/*/Manifest.xml; do',
90
+ '[ -f "$f" ] && { echo "@@M $f"; cat "$f"; echo; }; done;',
91
+ 'for t in cache/cubeCrystal/*/*/*.template; do [ -f "$t" ] && { echo "@@T $t"; head -c 400 "$t"; echo; }; done',
92
+ ].join(' ');
93
+ const result = runSync(adbPath(), ['-s', serial, 'exec-out', 'run-as', pkg, 'sh', '-c', script], { encoding: 'latin1' });
94
+ if (/not debuggable|unknown package|is unknown/i.test(result.stdout + result.stderr)) {
95
+ throw new CliError(`Cannot read ${pkg} on ${serial}: ${(result.stderr || result.stdout).trim()}`,
96
+ 'Installed versions can only be read from debug builds of the app (run-as).');
97
+ }
98
+ const manifests = [];
99
+ const templates = [];
100
+ for (const block of result.stdout.split(/\n(?=@@[MT] )/)) {
101
+ const match = block.match(/^@@([MT]) (\S+)\n?([\s\S]*)$/);
102
+ if (!match) continue;
103
+ (match[1] === 'M' ? manifests : templates).push({ file: match[2], text: match[3] });
104
+ }
105
+ return { manifests, templates, source: `Android ${serial} (${pkg})` };
106
+ }
107
+
108
+ // --- iOS simulator: the data container is a local directory ------------------------------------------
109
+
110
+ function readSimulator(target, devices) {
111
+ const bundleId = iosBundleId(devices);
112
+ const result = runSync('xcrun', ['simctl', 'get_app_container', target.id, bundleId, 'data']);
113
+ if (result.code !== 0) throw new CliError(`${bundleId} is not installed on simulator ${target.id}: ${result.stderr.trim()}`);
114
+ const container = result.stdout.trim();
115
+ const manifests = [];
116
+ for (const root of [path.join(container, 'Documents', 'NebulaEnvironments'), path.join(container, 'Documents')]) {
117
+ for (const file of findFiles(root, (rel) => /(^|\/)NAMAPP_UNZIP\/[^/]+\/Manifest\.xml$/.test(rel), root.endsWith('Documents') ? 3 : 4)) {
118
+ manifests.push({ file: path.relative(container, file), text: readFileSync(file, 'utf8') });
119
+ }
120
+ }
121
+ const cubeRoot = path.join(container, 'Library', 'Caches', 'cubeCrystal');
122
+ const templates = findFiles(cubeRoot, (rel) => rel.endsWith('.template'), 3)
123
+ .map((file) => ({ file: path.relative(container, file), text: readHead(file, 400) }));
124
+ return { manifests: dedupe(manifests), templates, source: `iOS simulator ${target.id} (${bundleId})` };
125
+ }
126
+
127
+ // --- iOS device over USB: go-ios house_arrest (development-signed builds) ---------------------------
128
+
129
+ async function readIosDevice(target, devices) {
130
+ const bundleId = iosBundleId(devices);
131
+ const ios = which('ios');
132
+ if (!ios) throw new CliError('go-ios not found', 'Install it with `npm i -g go-ios` (iOS 17+ also needs `ios tunnel start --userspace`).');
133
+ const base = ['fsync', '--udid', target.id, `--app=${bundleId}`];
134
+ const tree = (dir) => {
135
+ const result = runSync(ios, [...base, 'tree', `--path=${dir}`]);
136
+ return parseGoIosTree(result.stdout).map((rel) => `${dir}/${rel}`);
137
+ };
138
+ const files = [...tree('/Documents/NebulaEnvironments'), ...tree('/Library/Caches/cubeCrystal')];
139
+ if (files.length === 0) {
140
+ throw new CliError(`Could not read ${bundleId} on ${target.id}`,
141
+ 'The app must be a development build; on iOS 17+ keep `ios tunnel start --userspace` running.');
142
+ }
143
+ const work = mkdtempSync(path.join(os.tmpdir(), 'minicube-installed-'));
144
+ try {
145
+ // Each go-ios pull is a separate device session (~1-2 s). Over USB (iOS <= 16) they run in
146
+ // parallel; through the iOS 17+ tunnel parallel sessions fail or hang, so pull one at a time
147
+ // with a timeout per file.
148
+ const major = Number.parseInt(String(JSON.parse(runSync(ios, ['info', '--udid', target.id]).stdout || '{}').ProductVersion), 10) || 0;
149
+ const tunnelled = major >= 17;
150
+ const pull = async (remote) => {
151
+ const local = path.join(work, remote.replace(/[^\w.-]+/g, '_'));
152
+ const args = [...base, 'pull', `--srcPath=${remote}`, `--dstPath=${local}`];
153
+ if (tunnelled) runSync(ios, args, { timeout: 30_000 });
154
+ else await run(ios, args, { capture: true });
155
+ const file = existsSync(local) && statSync(local).isDirectory() ? path.join(local, path.basename(remote)) : local;
156
+ return existsSync(file) ? file : null;
157
+ };
158
+ const wanted = files.filter((remote) => /\/NAMAPP_UNZIP\/[^/]+\/Manifest\.xml$/.test(remote) || remote.endsWith('.template'));
159
+ if (tunnelled) log.detail(`Reading ${wanted.length} files one at a time through the iOS ${major} tunnel (about ${Math.ceil(wanted.length * 1.5)} s)`);
160
+ const pulled = await mapLimit(wanted, tunnelled ? 1 : 8, async (remote) => ({ remote, file: await pull(remote) }));
161
+ const manifests = [];
162
+ const templates = [];
163
+ for (const { remote, file } of pulled) {
164
+ if (!file) continue;
165
+ if (remote.endsWith('.template')) templates.push({ file: remote, text: readHead(file, 400) });
166
+ else manifests.push({ file: remote, text: readFileSync(file, 'utf8') });
167
+ }
168
+ return { manifests, templates, source: `iPhone ${target.id} (${bundleId})` };
169
+ } finally {
170
+ rmSync(work, { recursive: true, force: true });
171
+ }
172
+ }
173
+
174
+ /** go-ios prints `| |-name` lines (directories end with "/"); return paths relative to the root. */
175
+ export function parseGoIosTree(text) {
176
+ const lines = text.split(/\r?\n/).map((line) => line.match(/^((?:\|\s\s)*)\|-(.+)$/)).filter(Boolean);
177
+ if (lines.length === 0) return [];
178
+ const base = Math.min(...lines.map((m) => m[1].length / 3));
179
+ const stack = [];
180
+ const files = [];
181
+ for (const [, indent, rawName] of lines) {
182
+ const depth = indent.length / 3 - base;
183
+ const name = rawName.replace(/\/+$/, '');
184
+ stack.length = depth;
185
+ stack[depth] = name;
186
+ if (!rawName.endsWith('/')) files.push(stack.slice(0, depth + 1).join('/'));
187
+ }
188
+ return files;
189
+ }
190
+
191
+ function iosBundleId(devices) {
192
+ const bundleId = devices.ios?.bundleId;
193
+ if (!bundleId || bundleId.includes('<')) throw new CliError('devices.ios.bundleId is not configured');
194
+ return bundleId;
195
+ }
196
+
197
+ function findFiles(root, accept, maxDepth, depth = 0, rel = '') {
198
+ if (!existsSync(root) || depth > maxDepth) return [];
199
+ const out = [];
200
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
201
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
202
+ const full = path.join(root, entry.name);
203
+ if (entry.isDirectory()) out.push(...findFiles(full, accept, maxDepth, depth + 1, childRel));
204
+ else if (accept(childRel)) out.push(full);
205
+ }
206
+ return out;
207
+ }
208
+
209
+ async function mapLimit(items, limit, fn) {
210
+ const results = new Array(items.length);
211
+ let next = 0;
212
+ const worker = async () => {
213
+ while (next < items.length) {
214
+ const index = next++;
215
+ results[index] = await fn(items[index]);
216
+ }
217
+ };
218
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
219
+ return results;
220
+ }
221
+
222
+ function readHead(file, bytes) {
223
+ return readFileSync(file).subarray(0, bytes).toString('latin1');
224
+ }
225
+
226
+ function dedupe(items) {
227
+ return [...new Map(items.map((item) => [item.file, item])).values()];
228
+ }