@cityholdings/minicube-dev 0.0.0-stage → 0.1.1
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 +156 -2
- package/bin/minicube-dev.js +11 -0
- package/package.json +27 -4
- package/scripts/install.ps1 +13 -0
- package/scripts/install.sh +9 -0
- package/src/act.js +150 -0
- package/src/api.js +138 -0
- package/src/args.js +53 -0
- package/src/build.js +216 -0
- package/src/cli.js +432 -0
- package/src/config.js +126 -0
- package/src/device.js +73 -0
- package/src/kube.js +54 -0
- package/src/log.js +56 -0
- package/src/proc.js +91 -0
- package/src/workflow.js +113 -0
- package/src/zip.js +110 -0
package/src/build.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { CliError, log } from './log.js';
|
|
6
|
+
import { run, which } from './proc.js';
|
|
7
|
+
import { listFiles, zipDirectory } from './zip.js';
|
|
8
|
+
|
|
9
|
+
export const OUT_DIR = '.minicube';
|
|
10
|
+
|
|
11
|
+
/** Build the project into the archive the server expects. Returns { file, mainUrl, outputDir }. */
|
|
12
|
+
export async function buildProject(project, globalConfig, { version } = {}) {
|
|
13
|
+
const outRoot = path.join(project.root, OUT_DIR);
|
|
14
|
+
mkdirSync(outRoot, { recursive: true });
|
|
15
|
+
ensureGitIgnored(project.root);
|
|
16
|
+
switch (project.data.type) {
|
|
17
|
+
case 'mini':
|
|
18
|
+
return buildMini(project, globalConfig, outRoot);
|
|
19
|
+
case 'h5':
|
|
20
|
+
return buildH5(project, outRoot);
|
|
21
|
+
case 'card':
|
|
22
|
+
return buildCard(project, outRoot, version);
|
|
23
|
+
default:
|
|
24
|
+
throw new CliError(`Unknown project type ${project.data.type}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ensureGitIgnored(root) {
|
|
29
|
+
const file = path.join(root, '.gitignore');
|
|
30
|
+
const content = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
31
|
+
if (!content.split(/\r?\n/).includes(`${OUT_DIR}/`)) {
|
|
32
|
+
writeFileSync(file, `${content}${content && !content.endsWith('\n') ? '\n' : ''}${OUT_DIR}/\n`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------------------------
|
|
37
|
+
// Mini programs: the same compiler configuration the mPaaS cloud build uses for IDE uploads.
|
|
38
|
+
|
|
39
|
+
const MINI_REQUIRED_OUTPUTS = ['appConfig.json', 'index.html', 'index.js', 'index.worker.js', 'manifest.json'];
|
|
40
|
+
const MINI_SOURCE_EXCLUDES = new Set([OUT_DIR, '.git', '.idea', '.vscode', '.DS_Store']);
|
|
41
|
+
|
|
42
|
+
export function resolveMiniBuilder(globalConfig) {
|
|
43
|
+
const configured = process.env.MINICUBE_MINI_BUILDER ?? globalConfig.data.builders?.mini;
|
|
44
|
+
if (!configured || String(configured).includes('<')) {
|
|
45
|
+
throw new CliError(
|
|
46
|
+
'No mini program builder configured',
|
|
47
|
+
'Set builders.mini in the minicube-dev config to the mini-pkg-builder executable (or its runtime index.js), or export MINICUBE_MINI_BUILDER.',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const resolved = which(String(configured)) ?? path.resolve(String(configured));
|
|
51
|
+
if (!existsSync(resolved)) throw new CliError(`Mini program builder not found: ${resolved}`);
|
|
52
|
+
return resolved.endsWith('.js') || resolved.endsWith('.mjs')
|
|
53
|
+
? { command: process.execPath, prefix: [resolved] }
|
|
54
|
+
: { command: resolved, prefix: [] };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readMiniProjectConfig(sourceDir) {
|
|
58
|
+
const file = path.join(sourceDir, 'mini.project.json');
|
|
59
|
+
const config = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
|
|
60
|
+
return { ...config, enableAppxNg: config.enableAppxNg === true };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function buildMini(project, globalConfig, outRoot) {
|
|
64
|
+
const id = project.data.resourceId;
|
|
65
|
+
const sourceDir = path.resolve(project.root, project.data.sourceDir ?? '.');
|
|
66
|
+
if (!existsSync(path.join(sourceDir, 'app.json'))) {
|
|
67
|
+
throw new CliError(`${sourceDir} is not a mini program (no app.json)`);
|
|
68
|
+
}
|
|
69
|
+
const builder = resolveMiniBuilder(globalConfig);
|
|
70
|
+
const work = path.join(os.tmpdir(), `minicube-${id}-${Date.now()}`);
|
|
71
|
+
const input = path.join(work, 'input');
|
|
72
|
+
const output = path.join(outRoot, 'mini', id);
|
|
73
|
+
rmSync(output, { recursive: true, force: true });
|
|
74
|
+
mkdirSync(output, { recursive: true });
|
|
75
|
+
// Compile a copy: the builder writes caches next to the sources.
|
|
76
|
+
cpSync(sourceDir, input, {
|
|
77
|
+
recursive: true,
|
|
78
|
+
filter: (src) => !MINI_SOURCE_EXCLUDES.has(path.basename(src)) || src === sourceDir,
|
|
79
|
+
});
|
|
80
|
+
const projectConfig = readMiniProjectConfig(input);
|
|
81
|
+
const args = [
|
|
82
|
+
...builder.prefix,
|
|
83
|
+
'--project', '.', '--input', '.', '--output', output,
|
|
84
|
+
'--target', 'web', '--mode', 'concurrent', '--no-vuerender', '--zrender', '--rmlrender',
|
|
85
|
+
'--external', 'appx', '--appxcompatible', 'tiny',
|
|
86
|
+
'--project-config-json', encodeURIComponent(JSON.stringify(projectConfig)),
|
|
87
|
+
'--prod', '--no-minify',
|
|
88
|
+
];
|
|
89
|
+
log.step(`Compiling mini program ${id}`);
|
|
90
|
+
const logFile = path.join(outRoot, 'mini-build.log');
|
|
91
|
+
const result = await run(builder.command, args, { cwd: input, capture: true, env: { FORCE_COLOR: '0', NO_COLOR: '1' } });
|
|
92
|
+
writeFileSync(logFile, `${result.stdout}\n${result.stderr}`);
|
|
93
|
+
rmSync(work, { recursive: true, force: true });
|
|
94
|
+
if (result.code !== 0) {
|
|
95
|
+
throw new CliError(`Mini program compilation failed (exit ${result.code})`, `See ${logFile}\n${tail(result.stderr || result.stdout)}`);
|
|
96
|
+
}
|
|
97
|
+
removeDevelopmentFiles(output);
|
|
98
|
+
injectCloudHtmlMarkers(output);
|
|
99
|
+
const pages = validateMiniOutput(output);
|
|
100
|
+
log.detail(`${pages} page(s) registered, appxVersion ${projectConfig.enableAppxNg ? '2.0' : '1.0'}`);
|
|
101
|
+
|
|
102
|
+
const file = path.join(outRoot, `${id}.zip`);
|
|
103
|
+
zipDirectory(output, file, id);
|
|
104
|
+
const appJson = JSON.parse(readFileSync(path.join(sourceDir, 'app.json'), 'utf8'));
|
|
105
|
+
const firstPage = (appJson.pages ?? [])[0] ?? 'pages/index/index';
|
|
106
|
+
return { file, outputDir: output, mainUrl: project.data.mainUrl ?? `/index.html#${firstPage}` };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function tail(text, lines = 15) {
|
|
110
|
+
return String(text ?? '').trim().split(/\r?\n/).slice(-lines).join('\n');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function removeDevelopmentFiles(root) {
|
|
114
|
+
for (const rel of listFiles(root)) {
|
|
115
|
+
const base = path.posix.basename(rel);
|
|
116
|
+
if (base === '.DS_Store' || base === 'Thumbs.db' || (rel.startsWith('node_modules/') && /^licen[cs]e(?:\.|$)/i.test(base))) {
|
|
117
|
+
rmSync(path.join(root, rel), { force: true });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The cloud build marks index.html and pins the worker hash; the mPaaS runtime expects both. */
|
|
123
|
+
function injectCloudHtmlMarkers(root) {
|
|
124
|
+
const htmlFile = path.join(root, 'index.html');
|
|
125
|
+
const workerHash = createHash('sha256').update(readFileSync(path.join(root, 'index.worker.js'))).digest('hex').slice(0, 8);
|
|
126
|
+
let html = readFileSync(htmlFile, 'utf8').replace(/index\.worker\.js\?hash=[^'"\s]+/, `index.worker.js?hash=${workerHash}`);
|
|
127
|
+
if (!html.includes('__CLOUD-BUILD_START__')) {
|
|
128
|
+
if (!html.includes('<body>')) throw new CliError('builder index.html has no <body>');
|
|
129
|
+
html = html.replace('<body>', `<body><!-- __BUGME_START__ --> <script>window.__BUGME_ENV__='preview';</script> <!-- __BUGME_END__ -->\n<!-- __CLOUD-BUILD_START__ --> <!-- __CLOUD-BUILD_END__ -->`);
|
|
130
|
+
}
|
|
131
|
+
writeFileSync(htmlFile, html);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Fail before upload when a declared page would end up PAGE_NOT_FOUND on the device. */
|
|
135
|
+
export function validateMiniOutput(root) {
|
|
136
|
+
for (const rel of MINI_REQUIRED_OUTPUTS) {
|
|
137
|
+
const file = path.join(root, rel);
|
|
138
|
+
if (!existsSync(file) || statSync(file).size === 0) throw new CliError(`builder output is missing ${rel}`);
|
|
139
|
+
}
|
|
140
|
+
const appConfig = JSON.parse(readFileSync(path.join(root, 'appConfig.json'), 'utf8'));
|
|
141
|
+
const declared = new Set(Array.isArray(appConfig.pages) ? appConfig.pages : []);
|
|
142
|
+
for (const sub of appConfig.subPackages ?? appConfig.subpackages ?? []) {
|
|
143
|
+
const subRoot = typeof sub.root === 'string' ? sub.root.replace(/\/$/, '') : '';
|
|
144
|
+
for (const page of sub.pages ?? []) declared.add(subRoot ? `${subRoot}/${page}` : page);
|
|
145
|
+
}
|
|
146
|
+
const bundle = readFileSync(path.join(root, 'index.js'), 'utf8');
|
|
147
|
+
const registered = new Set([...bundle.matchAll(/\$AppxRegistry\.registerPageRenderConfig\(\{[\s\S]{0,1200}?pagePath:\s*["']([^"']+)["']/g)].map((m) => m[1]));
|
|
148
|
+
const missing = [...declared].filter((page) => !registered.has(page));
|
|
149
|
+
if (missing.length) throw new CliError(`render bundle is missing ${missing.length}/${declared.size} page registrations: ${missing.slice(0, 5).join(', ')}`);
|
|
150
|
+
return registered.size;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------------------------
|
|
154
|
+
// H5 offline packages: optional build command, then <id>/www/<output>.
|
|
155
|
+
|
|
156
|
+
async function buildH5(project, outRoot) {
|
|
157
|
+
const id = project.data.resourceId;
|
|
158
|
+
const h5 = project.data.h5 ?? {};
|
|
159
|
+
if (h5.buildCommand) {
|
|
160
|
+
log.step(`Running ${h5.buildCommand}`);
|
|
161
|
+
const shell = process.platform === 'win32' ? process.env.ComSpec ?? 'cmd.exe' : '/bin/sh';
|
|
162
|
+
const shellArgs = process.platform === 'win32' ? ['/d', '/s', '/c', h5.buildCommand] : ['-c', h5.buildCommand];
|
|
163
|
+
const result = await run(shell, shellArgs, { cwd: project.root });
|
|
164
|
+
if (result.code !== 0) throw new CliError(`H5 build command failed (exit ${result.code})`);
|
|
165
|
+
}
|
|
166
|
+
const wwwSource = path.resolve(project.root, h5.outputDir ?? 'www');
|
|
167
|
+
if (!existsSync(path.join(wwwSource, 'index.html')) && !h5.mainUrl && !project.data.mainUrl) {
|
|
168
|
+
throw new CliError(`${wwwSource} has no index.html`, 'Set h5.outputDir to the build output, or mainUrl to the entry page.');
|
|
169
|
+
}
|
|
170
|
+
const file = path.join(outRoot, `${id}.zip`);
|
|
171
|
+
zipDirectory(wwwSource, file, `${id}/www`);
|
|
172
|
+
log.detail(`${listFiles(wwwSource).length} file(s) from ${path.relative(project.root, wwwSource) || '.'}`);
|
|
173
|
+
return { file, outputDir: wwwSource, mainUrl: project.data.mainUrl ?? '/www/index.html' };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------------------------
|
|
177
|
+
// Cards: cube-pack (inlines local images) when installed, otherwise the project's own `act build`.
|
|
178
|
+
|
|
179
|
+
export function resolveAct(projectRoot) {
|
|
180
|
+
const local = which('act', [path.join(projectRoot, 'node_modules', '.bin')]);
|
|
181
|
+
if (local && local.startsWith(projectRoot)) return local;
|
|
182
|
+
return local ?? null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function cardDir(project) {
|
|
186
|
+
return path.resolve(project.root, project.data.card?.dir ?? '.');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function buildCard(project, outRoot) {
|
|
190
|
+
const dir = cardDir(project);
|
|
191
|
+
if (!existsSync(path.join(dir, 'manifest.json'))) throw new CliError(`${dir} is not a card (no manifest.json)`);
|
|
192
|
+
const cubePack = project.data.card?.useCubePack === false ? null : which('cube-pack');
|
|
193
|
+
log.step(`Building card ${project.data.resourceId}`);
|
|
194
|
+
let result;
|
|
195
|
+
if (cubePack) {
|
|
196
|
+
result = await run(cubePack, ['build'], { cwd: project.root });
|
|
197
|
+
} else {
|
|
198
|
+
const act = resolveAct(project.root);
|
|
199
|
+
if (!act) throw new CliError('ACT not found', 'Install it in the card project: npm i -D @antdigital/cube');
|
|
200
|
+
result = await run(act, ['build', dir], { cwd: project.root });
|
|
201
|
+
}
|
|
202
|
+
if (result.code !== 0) throw new CliError(`Card build failed (exit ${result.code})`);
|
|
203
|
+
const zip = findCardZip(dir);
|
|
204
|
+
const file = path.join(outRoot, `${project.data.resourceId}.zip`);
|
|
205
|
+
cpSync(zip, file);
|
|
206
|
+
return { file, outputDir: path.dirname(zip), mainUrl: null };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function findCardZip(dir) {
|
|
210
|
+
const dist = path.join(dir, 'dist');
|
|
211
|
+
const candidates = existsSync(dist) ? listFiles(dist).filter((rel) => rel.endsWith('/main.zip') || rel === 'main.zip') : [];
|
|
212
|
+
if (candidates.length !== 1) {
|
|
213
|
+
throw new CliError(`Expected exactly one main.zip under ${dist}, found ${candidates.length}`);
|
|
214
|
+
}
|
|
215
|
+
return path.join(dist, candidates[0]);
|
|
216
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { advertisedHost, attachDevice, deliverCard, ensureActServer } from './act.js';
|
|
6
|
+
import { connect, connectAdmin } from './api.js';
|
|
7
|
+
import { list, one, parseArgs } from './args.js';
|
|
8
|
+
import { buildProject, resolveMiniBuilder } from './build.js';
|
|
9
|
+
import {
|
|
10
|
+
CONFIG_TEMPLATE, PROJECT_FILE, globalConfigPath, loadGlobalConfig, loadProject, resolveEnv, saveGlobalConfig, saveProject, validateProject,
|
|
11
|
+
} from './config.js';
|
|
12
|
+
import { openUrl, parseTarget } from './device.js';
|
|
13
|
+
import { CliError, color, log, table } from './log.js';
|
|
14
|
+
import { which } from './proc.js';
|
|
15
|
+
import { chooseVersion, previewUrl, resolveWhitelistIds, uploadMeta } from './workflow.js';
|
|
16
|
+
import { fileSize } from './zip.js';
|
|
17
|
+
|
|
18
|
+
const pkg = JSON.parse(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
|
|
19
|
+
|
|
20
|
+
const HELP = `minicube-dev ${pkg.version} — build, debug, preview and release mPaaS mini programs, H5 offline packages and Cube cards
|
|
21
|
+
|
|
22
|
+
Usage: minicube-dev <command> [options]
|
|
23
|
+
|
|
24
|
+
Project
|
|
25
|
+
init [--type mini|h5|card] [--id <resourceId>] create minicube.json in the current directory
|
|
26
|
+
build [dir] build the upload archive into .minicube/
|
|
27
|
+
|
|
28
|
+
Develop
|
|
29
|
+
debug [dir] [--whitelist <id|name>] [--device <target>]
|
|
30
|
+
mini/h5: build, upload a debug package visible to the whitelist, print the preview link
|
|
31
|
+
and open it on the device; card: build and push over the local ACT server
|
|
32
|
+
--whitelist-create <name> --items <userIds> create a whitelist first
|
|
33
|
+
--items <userIds> add members to the (first) whitelist
|
|
34
|
+
--remote card only: upload a whitelist-gray card instead of a local push
|
|
35
|
+
preview [<url>|dir] --device <target> open a preview link, or the project's latest debug package
|
|
36
|
+
|
|
37
|
+
Release
|
|
38
|
+
release [dir] [--version x.y.z] [--publish full|whitelist] [--whitelist <id|name>] [--yes]
|
|
39
|
+
build and upload a release package; --publish also publishes it
|
|
40
|
+
publish <packageId> --mode full|whitelist|timewindow [--whitelist <ids>] [--end <ISO time> --count <n>] [--yes]
|
|
41
|
+
|
|
42
|
+
Remote data
|
|
43
|
+
resources [--type mini|h5|card] packages [resourceId] whoami | token status
|
|
44
|
+
whitelist list | show <id> | create <name> --items <ids> | add <id> --items <ids> | remove <id> --items <ids>
|
|
45
|
+
|
|
46
|
+
Configuration
|
|
47
|
+
config init | path | show | set-token <env> <token> env
|
|
48
|
+
admin token list|create|rotate|revoke (kubectl access to the environment; see README)
|
|
49
|
+
doctor
|
|
50
|
+
|
|
51
|
+
Device targets: android, android:<serial>, ios-sim, ios-sim:<udid>, ios:<udid>
|
|
52
|
+
Global options: --env <name> --config <file> --json --quiet
|
|
53
|
+
Config file: ${globalConfigPath()} (override with MINICUBE_DEV_CONFIG or --config)`;
|
|
54
|
+
|
|
55
|
+
export async function main(argv) {
|
|
56
|
+
const { positionals, flags } = parseArgs(argv);
|
|
57
|
+
const [command, ...rest] = positionals;
|
|
58
|
+
log.quiet = Boolean(flags.quiet);
|
|
59
|
+
if (flags.version === true && !command) {
|
|
60
|
+
log.out(pkg.version);
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
if (!command || command === 'help' || flags.help) {
|
|
64
|
+
log.out(HELP);
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
const ctx = { flags, args: rest };
|
|
68
|
+
const handler = COMMANDS[command];
|
|
69
|
+
if (!handler) throw new CliError(`Unknown command: ${command}`, 'Run `minicube-dev help`.');
|
|
70
|
+
return (await handler(ctx)) ?? 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function output(flags, value, render) {
|
|
74
|
+
if (flags.json) log.out(value);
|
|
75
|
+
else log.out(render(value));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function withApi(ctx, fn, project = null) {
|
|
79
|
+
const config = loadGlobalConfig(ctx.flags);
|
|
80
|
+
const env = resolveEnv(config, ctx.flags, project);
|
|
81
|
+
const api = await connect(env);
|
|
82
|
+
try {
|
|
83
|
+
return await fn(api, env, config);
|
|
84
|
+
} finally {
|
|
85
|
+
api.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function confirm(flags, question) {
|
|
90
|
+
if (flags.yes) return true;
|
|
91
|
+
if (!process.stdin.isTTY) throw new CliError(`${question} — refusing without --yes in a non-interactive shell`);
|
|
92
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
93
|
+
const answer = await rl.question(`${question} [y/N] `);
|
|
94
|
+
rl.close();
|
|
95
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function projectFrom(ctx) {
|
|
99
|
+
return loadProject(path.resolve(ctx.args[0] && !ctx.args[0].includes('://') ? ctx.args[0] : '.'));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const COMMANDS = {
|
|
103
|
+
async version() {
|
|
104
|
+
log.out(pkg.version);
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async help() {
|
|
108
|
+
log.out(HELP);
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
// ------------------------------------------------------------------ configuration
|
|
112
|
+
async config({ flags, args }) {
|
|
113
|
+
const [sub, ...rest] = args;
|
|
114
|
+
const config = loadGlobalConfig(flags);
|
|
115
|
+
if (sub === 'path') return log.out(config.file);
|
|
116
|
+
if (sub === 'init') {
|
|
117
|
+
if (config.exists && !flags.force) throw new CliError(`${config.file} already exists`, 'Use --force to overwrite it.');
|
|
118
|
+
config.data = structuredClone(CONFIG_TEMPLATE);
|
|
119
|
+
saveGlobalConfig(config);
|
|
120
|
+
log.success(`Wrote ${config.file}; replace the <placeholders> with your environments`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (sub === 'set-token') {
|
|
124
|
+
const [envName, token] = rest;
|
|
125
|
+
if (!envName || !token) throw new CliError('Usage: minicube-dev config set-token <env> <token>');
|
|
126
|
+
if (!config.data.environments[envName]) throw new CliError(`Environment ${envName} is not configured`);
|
|
127
|
+
config.data.environments[envName].token = token;
|
|
128
|
+
saveGlobalConfig(config);
|
|
129
|
+
log.success(`Saved token for ${envName}`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (sub === 'show' || !sub) {
|
|
133
|
+
const redacted = structuredClone(config.data);
|
|
134
|
+
for (const env of Object.values(redacted.environments ?? {})) {
|
|
135
|
+
if (env.token && !String(env.token).startsWith('<')) env.token = `${String(env.token).slice(0, 12)}…`;
|
|
136
|
+
}
|
|
137
|
+
log.info(color.dim(config.exists ? config.file : `${config.file} (missing — run \`minicube-dev config init\`)`));
|
|
138
|
+
return log.out(redacted);
|
|
139
|
+
}
|
|
140
|
+
throw new CliError(`Unknown config subcommand ${sub}`);
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
async env({ flags }) {
|
|
144
|
+
const config = loadGlobalConfig(flags);
|
|
145
|
+
const rows = Object.entries(config.data.environments).map(([name, e]) => ({
|
|
146
|
+
name: name === config.data.defaultEnv ? `${name} *` : name,
|
|
147
|
+
url: e.baseUrl ?? (e.kube ? `kube:${e.kube.namespace}/${e.kube.service}` : ''),
|
|
148
|
+
token: e.tokenEnv ? `$${e.tokenEnv}` : e.token && !String(e.token).startsWith('<') ? `${String(e.token).slice(0, 12)}…` : '(none)',
|
|
149
|
+
description: e.description ?? '',
|
|
150
|
+
}));
|
|
151
|
+
output(flags, rows, (r) => table(r, [{ key: 'name', title: 'ENV' }, { key: 'url', title: 'ENDPOINT' }, { key: 'token', title: 'TOKEN' }, { key: 'description', title: 'DESCRIPTION' }]));
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
async init({ flags }) {
|
|
155
|
+
const file = path.resolve(PROJECT_FILE);
|
|
156
|
+
if (loadProject('.', { required: false })?.file === file && !flags.force) throw new CliError(`${PROJECT_FILE} already exists`);
|
|
157
|
+
let type = one(flags.type);
|
|
158
|
+
if (!type) {
|
|
159
|
+
const exists = (f) => { try { readFileSync(f); return true; } catch { return false; } };
|
|
160
|
+
type = exists('app.json') ? 'mini' : exists('manifest.json') || exists('.act.config.json') ? 'card' : 'h5';
|
|
161
|
+
}
|
|
162
|
+
const data = { type, resourceId: one(flags.id) ?? '<resource id>' };
|
|
163
|
+
if (type === 'mini') data.debug = { whitelist: '<whitelist id or name>' };
|
|
164
|
+
if (type === 'h5') Object.assign(data, { h5: { buildCommand: 'npm run build', outputDir: 'dist' }, mainUrl: '/www/index.html', debug: { whitelist: '<whitelist id or name>' } });
|
|
165
|
+
if (type === 'card') data.card = { dir: '.' };
|
|
166
|
+
if (!data.resourceId.includes('<')) validateProject(data, file);
|
|
167
|
+
saveProject({ file, data });
|
|
168
|
+
log.success(`Wrote ${file}`);
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
// ------------------------------------------------------------------ remote data
|
|
172
|
+
async whoami(ctx) {
|
|
173
|
+
return withApi(ctx, async (api, env) => {
|
|
174
|
+
const me = await api.whoami();
|
|
175
|
+
output(ctx.flags, { env: env.name, ...me }, (m) =>
|
|
176
|
+
`${color.bold(m.env)} app ${m.appId}/${m.workspaceId} token ${m.name} (${m.tokenPrefix}…)\nexpires ${m.expiresAt} (${m.daysLeft} days left)${m.rotatedToId ? color.yellow(' — rotated, switch to the new token') : ''}`);
|
|
177
|
+
if (me.daysLeft < 30) log.warn(`Token expires in ${me.daysLeft} days; ask an administrator to rotate it (minicube-dev admin token rotate).`);
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
async token(ctx) {
|
|
182
|
+
if (ctx.args[0] && ctx.args[0] !== 'status') throw new CliError('Usage: minicube-dev token status');
|
|
183
|
+
return COMMANDS.whoami(ctx);
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
async resources(ctx) {
|
|
187
|
+
return withApi(ctx, async (api) => {
|
|
188
|
+
const rows = await api.resources(one(ctx.flags.type));
|
|
189
|
+
output(ctx.flags, rows, (r) => table(r, [{ key: 'type', title: 'TYPE' }, { key: 'resourceId', title: 'ID' }, { key: 'name', title: 'NAME' }]));
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
async packages(ctx) {
|
|
194
|
+
const project = ctx.args[0] ? null : loadProject('.', { required: false });
|
|
195
|
+
const id = ctx.args[0] ?? project?.data.resourceId;
|
|
196
|
+
if (!id) throw new CliError('Usage: minicube-dev packages <resourceId>');
|
|
197
|
+
return withApi(ctx, async (api) => {
|
|
198
|
+
const rows = await api.packages(id, Number(one(ctx.flags.limit) ?? 20));
|
|
199
|
+
output(ctx.flags, rows, (r) => table(r, [
|
|
200
|
+
{ key: 'packageId', title: 'PACKAGE' }, { key: 'version', title: 'VERSION' }, { key: 'channel', title: 'CHANNEL' },
|
|
201
|
+
{ key: 'status', title: 'STATUS' }, { key: 'publishMode', title: 'PUBLISH' }, { key: 'createdBy', title: 'BY' }, { key: 'createdAt', title: 'CREATED' },
|
|
202
|
+
]));
|
|
203
|
+
}, project);
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
async whitelist(ctx) {
|
|
207
|
+
const [sub = 'list', target] = ctx.args;
|
|
208
|
+
return withApi(ctx, async (api) => {
|
|
209
|
+
const show = (w) => output(ctx.flags, w, (v) => `#${v.id} ${v.name} (${v.itemCount} member(s))${v.items ? `\n${v.items.map((i) => ` ${i}`).join('\n')}` : ''}`);
|
|
210
|
+
if (sub === 'list') {
|
|
211
|
+
const rows = await api.whitelists();
|
|
212
|
+
return output(ctx.flags, rows, (r) => table(r, [{ key: 'id', title: 'ID' }, { key: 'name', title: 'NAME' }, { key: 'itemCount', title: 'MEMBERS' }]));
|
|
213
|
+
}
|
|
214
|
+
if (!target) throw new CliError(`Usage: minicube-dev whitelist ${sub} <${sub === 'create' ? 'name' : 'id'}>`);
|
|
215
|
+
if (sub === 'show') return show(await api.whitelist(target));
|
|
216
|
+
if (sub === 'create') return show(await api.createWhitelist(target, list(ctx.flags.items)));
|
|
217
|
+
if (sub === 'add') return show(await api.addWhitelistItems(target, list(ctx.flags.items)));
|
|
218
|
+
if (sub === 'remove') return show(await api.removeWhitelistItems(target, list(ctx.flags.items)));
|
|
219
|
+
throw new CliError(`Unknown whitelist subcommand ${sub}`);
|
|
220
|
+
});
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
// ------------------------------------------------------------------ build / debug / preview
|
|
224
|
+
async build(ctx) {
|
|
225
|
+
const project = projectFrom(ctx);
|
|
226
|
+
const config = loadGlobalConfig(ctx.flags);
|
|
227
|
+
const built = await buildProject(project, config);
|
|
228
|
+
log.success(`Built ${path.relative(process.cwd(), built.file) || built.file} (${(fileSize(built.file) / 1024).toFixed(1)} KB)`);
|
|
229
|
+
if (ctx.flags.json) log.out(built);
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
async debug(ctx) {
|
|
233
|
+
const project = projectFrom(ctx);
|
|
234
|
+
const { flags } = ctx;
|
|
235
|
+
if (project.data.type === 'card' && !flags.remote) return cardLocalPreview(ctx, project);
|
|
236
|
+
return withApi(ctx, async (api, env, config) => {
|
|
237
|
+
const resource = await api.resource(project.data.resourceId);
|
|
238
|
+
if (resource.type !== project.data.type) throw new CliError(`${resource.resourceId} is a ${resource.type} on the server, but minicube.json says ${project.data.type}`);
|
|
239
|
+
const whitelistIds = await resolveWhitelistIds(api, flags, project);
|
|
240
|
+
const version = chooseVersion(flags, project, await api.packages(resource.resourceId, 200));
|
|
241
|
+
const built = await buildProject(project, config, { version });
|
|
242
|
+
log.step(`Uploading debug ${resource.type} ${resource.resourceId}@${version} to ${env.name} (whitelists ${whitelistIds.join(', ')})`);
|
|
243
|
+
const uploaded = await api.upload(uploadMeta(project, { version, channel: 'debug', mainUrl: built.mainUrl, whitelistIds, description: one(flags.message) }), built.file);
|
|
244
|
+
const url = previewUrl(resource.type, resource.resourceId, uploaded.version, env.previewScheme);
|
|
245
|
+
log.success(`Package #${uploaded.packageId} ${uploaded.version} is ${uploaded.status} for whitelist ${uploaded.whitelistIds}`);
|
|
246
|
+
if (flags.json) log.out({ ...uploaded, previewUrl: url });
|
|
247
|
+
else if (url) log.out(url);
|
|
248
|
+
if (url && flags.device) openUrl(parseTarget(one(flags.device), config.data.devices), url, config.data.devices);
|
|
249
|
+
}, project);
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
async preview(ctx) {
|
|
253
|
+
const { flags, args } = ctx;
|
|
254
|
+
const config = loadGlobalConfig(flags);
|
|
255
|
+
const devices = config.data.devices ?? {};
|
|
256
|
+
if (args[0]?.includes('://')) {
|
|
257
|
+
openUrl(parseTarget(one(flags.device), devices), args[0], devices);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const project = projectFrom(ctx);
|
|
261
|
+
if (project.data.type === 'card') return cardLocalPreview(ctx, project);
|
|
262
|
+
return withApi(ctx, async (api, env) => {
|
|
263
|
+
const packages = await api.packages(project.data.resourceId, 50);
|
|
264
|
+
const version = one(flags.version);
|
|
265
|
+
const target = packages.find((p) => (version ? p.version === version : p.channel === 'debug'));
|
|
266
|
+
if (!target) throw new CliError(version ? `No package ${version}` : 'No debug package yet', 'Run `minicube-dev debug` first.');
|
|
267
|
+
const url = previewUrl(target.type, target.resourceId, target.version, env.previewScheme);
|
|
268
|
+
log.out(url);
|
|
269
|
+
if (flags.device) openUrl(parseTarget(one(flags.device), devices), url, devices);
|
|
270
|
+
}, project);
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
// ------------------------------------------------------------------ release
|
|
274
|
+
async release(ctx) {
|
|
275
|
+
const project = projectFrom(ctx);
|
|
276
|
+
const { flags } = ctx;
|
|
277
|
+
const mode = flags.publish === true ? 'full' : one(flags.publish);
|
|
278
|
+
return withApi(ctx, async (api, env, config) => {
|
|
279
|
+
const resource = await api.resource(project.data.resourceId);
|
|
280
|
+
if (resource.type !== project.data.type) throw new CliError(`${resource.resourceId} is a ${resource.type} on the server, but minicube.json says ${project.data.type}`);
|
|
281
|
+
const version = chooseVersion(flags, project, await api.packages(resource.resourceId, 200));
|
|
282
|
+
if (mode === 'full' && !(await confirm(flags, `Build, upload and publish ${resource.resourceId}@${version} to ALL users of ${env.name}?`))) {
|
|
283
|
+
throw new CliError('Cancelled');
|
|
284
|
+
}
|
|
285
|
+
const whitelistIds = mode === 'whitelist' ? await resolveWhitelistIds(api, flags, project) : undefined;
|
|
286
|
+
const built = await buildProject(project, config, { version });
|
|
287
|
+
log.step(`Uploading release ${resource.type} ${resource.resourceId}@${version} to ${env.name}`);
|
|
288
|
+
let result = await api.upload(uploadMeta(project, { version, channel: 'release', mainUrl: built.mainUrl, description: one(flags.message) }), built.file);
|
|
289
|
+
log.success(`Release package #${result.packageId} ${result.version} uploaded (${result.status})`);
|
|
290
|
+
if (mode) {
|
|
291
|
+
result = await api.publish(result.packageId, { mode, whitelistIds, description: one(flags.message) });
|
|
292
|
+
log.success(`Published #${result.packageId} (${result.publishMode}, ${result.status})`);
|
|
293
|
+
} else {
|
|
294
|
+
log.info(`Publish it with: minicube-dev publish ${result.packageId} --env ${env.name} --mode full`);
|
|
295
|
+
}
|
|
296
|
+
output(flags, result, (r) => `${r.packageId}`);
|
|
297
|
+
}, project);
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
async publish(ctx) {
|
|
301
|
+
const { flags, args } = ctx;
|
|
302
|
+
const id = args[0];
|
|
303
|
+
const mode = one(flags.mode) ?? 'full';
|
|
304
|
+
if (!id) throw new CliError('Usage: minicube-dev publish <packageId> --mode full|whitelist|timewindow');
|
|
305
|
+
return withApi(ctx, async (api, env) => {
|
|
306
|
+
const pkgInfo = await api.pkg(id);
|
|
307
|
+
const body = { mode, description: one(flags.message) };
|
|
308
|
+
if (mode === 'whitelist') body.whitelistIds = await resolveWhitelistIds(api, flags, loadProject('.', { required: false }));
|
|
309
|
+
if (mode === 'timewindow') {
|
|
310
|
+
const end = Date.parse(one(flags.end) ?? '');
|
|
311
|
+
if (!Number.isFinite(end) || !one(flags.count)) throw new CliError('timewindow needs --end <ISO time> and --count <n>');
|
|
312
|
+
Object.assign(body, { endTime: end, count: Number(one(flags.count)) });
|
|
313
|
+
}
|
|
314
|
+
if (mode === 'full' && !(await confirm(flags, `Publish ${pkgInfo.resourceId}@${pkgInfo.version} (#${id}) to ALL users of ${env.name}?`))) {
|
|
315
|
+
throw new CliError('Cancelled');
|
|
316
|
+
}
|
|
317
|
+
const result = await api.publish(id, body);
|
|
318
|
+
log.success(`Published #${result.packageId} ${result.resourceId}@${result.version} (${result.publishMode}, ${result.status})`);
|
|
319
|
+
if (flags.json) log.out(result);
|
|
320
|
+
});
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
// ------------------------------------------------------------------ administration
|
|
324
|
+
async admin(ctx) {
|
|
325
|
+
const [area, sub = 'list', target] = ctx.args;
|
|
326
|
+
if (area !== 'token') throw new CliError('Usage: minicube-dev admin token list|create|rotate|revoke');
|
|
327
|
+
const { flags } = ctx;
|
|
328
|
+
const config = loadGlobalConfig(flags);
|
|
329
|
+
const env = resolveEnv(config, flags);
|
|
330
|
+
const admin = await connectAdmin(env);
|
|
331
|
+
const operator = one(flags.operator) ?? process.env.USER ?? process.env.USERNAME ?? 'unknown';
|
|
332
|
+
const saveIfAsked = (vo) => {
|
|
333
|
+
if (!flags.save) return;
|
|
334
|
+
config.data.environments[env.name].token = vo.token;
|
|
335
|
+
saveGlobalConfig(config);
|
|
336
|
+
log.success(`Saved the new token into ${config.file} for ${env.name}`);
|
|
337
|
+
};
|
|
338
|
+
const showIssued = (vo) => {
|
|
339
|
+
log.success(`Token #${vo.id} "${vo.name}" for ${vo.appId}/${vo.workspaceId}, expires ${vo.expiresAt}`);
|
|
340
|
+
log.info(color.yellow('This is the only time the token is shown:'));
|
|
341
|
+
log.out(vo.token);
|
|
342
|
+
};
|
|
343
|
+
try {
|
|
344
|
+
if (sub === 'list') {
|
|
345
|
+
const rows = (await admin.list(one(flags.app))).map((t) => ({
|
|
346
|
+
...t,
|
|
347
|
+
state: t.revokedAt ? 'revoked' : t.daysLeft < 0 ? 'expired' : t.rotatedToId ? `rotated→#${t.rotatedToId}` : 'active',
|
|
348
|
+
}));
|
|
349
|
+
return output(flags, rows, (r) => table(r, [
|
|
350
|
+
{ key: 'id', title: 'ID' }, { key: 'name', title: 'NAME' }, { key: 'appId', title: 'APP' }, { key: 'workspaceId', title: 'WORKSPACE' },
|
|
351
|
+
{ key: 'tokenPrefix', title: 'PREFIX' }, { key: 'expiresAt', title: 'EXPIRES' }, { key: 'state', title: 'STATE' }, { key: 'lastUsedAt', title: 'LAST USED' },
|
|
352
|
+
]));
|
|
353
|
+
}
|
|
354
|
+
if (sub === 'create') {
|
|
355
|
+
const name = one(flags.name);
|
|
356
|
+
const appId = one(flags.app);
|
|
357
|
+
if (!name || !appId) throw new CliError('Usage: minicube-dev admin token create --env <env> --name <purpose> --app <appId> [--workspace default] [--days 365] [--save]');
|
|
358
|
+
const vo = await admin.issue({ name, appId, workspaceId: one(flags.workspace) ?? 'default', validDays: flags.days ? Number(one(flags.days)) : undefined, operator });
|
|
359
|
+
showIssued(vo);
|
|
360
|
+
return saveIfAsked(vo);
|
|
361
|
+
}
|
|
362
|
+
if (sub === 'rotate') {
|
|
363
|
+
if (!target) throw new CliError('Usage: minicube-dev admin token rotate <id> [--days 365] [--grace 7] [--save]');
|
|
364
|
+
const vo = await admin.rotate(target, { validDays: flags.days ? Number(one(flags.days)) : undefined, graceDays: flags.grace !== undefined ? Number(one(flags.grace)) : undefined, operator });
|
|
365
|
+
showIssued(vo);
|
|
366
|
+
log.info(`The old token stays valid for the grace period (default 7 days).`);
|
|
367
|
+
return saveIfAsked(vo);
|
|
368
|
+
}
|
|
369
|
+
if (sub === 'revoke') {
|
|
370
|
+
if (!target) throw new CliError('Usage: minicube-dev admin token revoke <id>');
|
|
371
|
+
if (!(await confirm(flags, `Revoke token #${target} in ${env.name}?`))) throw new CliError('Cancelled');
|
|
372
|
+
await admin.revoke(target, { operator });
|
|
373
|
+
return log.success(`Revoked token #${target}`);
|
|
374
|
+
}
|
|
375
|
+
throw new CliError(`Unknown admin token subcommand ${sub}`);
|
|
376
|
+
} finally {
|
|
377
|
+
admin.close();
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
async doctor(ctx) {
|
|
382
|
+
const checks = [];
|
|
383
|
+
const add = (name, ok, detail) => checks.push({ name, ok, detail });
|
|
384
|
+
add('node', Number(process.versions.node.split('.')[0]) >= 18, process.version);
|
|
385
|
+
const config = loadGlobalConfig(ctx.flags);
|
|
386
|
+
add('config', config.exists, config.file);
|
|
387
|
+
try {
|
|
388
|
+
add('mini builder', true, resolveMiniBuilder(config).prefix[0] ?? resolveMiniBuilder(config).command);
|
|
389
|
+
} catch (e) {
|
|
390
|
+
add('mini builder', false, e.message);
|
|
391
|
+
}
|
|
392
|
+
for (const tool of ['adb', 'kubectl', 'cube-pack']) add(tool, Boolean(which(tool)), which(tool) ?? 'not found');
|
|
393
|
+
if (process.platform === 'darwin') add('xcrun', Boolean(which('xcrun')), which('xcrun') ?? 'not found (iOS preview unavailable)');
|
|
394
|
+
const project = loadProject('.', { required: false });
|
|
395
|
+
if (project) add('project', true, `${project.data.type} ${project.data.resourceId} (${project.file})`);
|
|
396
|
+
for (const [name] of Object.entries(config.data.environments)) {
|
|
397
|
+
try {
|
|
398
|
+
const env = resolveEnv(config, { env: name });
|
|
399
|
+
const api = await connect(env);
|
|
400
|
+
try {
|
|
401
|
+
const me = await api.whoami();
|
|
402
|
+
add(`env ${name}`, me.daysLeft >= 0, `${me.appId}/${me.workspaceId}, token expires in ${me.daysLeft} days`);
|
|
403
|
+
} finally {
|
|
404
|
+
api.close();
|
|
405
|
+
}
|
|
406
|
+
} catch (e) {
|
|
407
|
+
add(`env ${name}`, false, e.message);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
output(ctx.flags, checks, (rows) => rows.map((c) => `${c.ok ? color.green('✔') : color.red('✖')} ${c.name.padEnd(14)} ${c.detail}`).join('\n'));
|
|
411
|
+
return checks.every((c) => c.ok || ['adb', 'kubectl', 'cube-pack', 'xcrun', 'mini builder'].includes(c.name)) ? 0 : 1;
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
async function cardLocalPreview(ctx, project) {
|
|
416
|
+
const { flags } = ctx;
|
|
417
|
+
const config = loadGlobalConfig(flags);
|
|
418
|
+
const env = (() => {
|
|
419
|
+
try {
|
|
420
|
+
return resolveEnv(config, flags, project);
|
|
421
|
+
} catch {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
})();
|
|
425
|
+
if (!flags['skip-build']) await buildProject(project, config);
|
|
426
|
+
log.step('Pushing the card over the local ACT server (no upload)');
|
|
427
|
+
const target = flags.device ? parseTarget(one(flags.device), config.data.devices) : null;
|
|
428
|
+
const act = await ensureActServer(project, undefined, advertisedHost(target));
|
|
429
|
+
if (target) await attachDevice(target, env, config.data.devices ?? {});
|
|
430
|
+
await deliverCard(project, act);
|
|
431
|
+
log.success('Card delivered');
|
|
432
|
+
}
|