@glass-sdk/cli 0.0.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/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.0.1
4
+
5
+ Breaking internal rewrite: one binary file-picker capability, source-independent
6
+ release configuration, target/mode-isolated generation, explicit signing, and
7
+ packed-consumer verification. See the repository README for the current contract.
package/README.md ADDED
@@ -0,0 +1,10 @@
1
+ # @glass-sdk/cli
2
+
3
+ Commands: `glass setup`, `glass run <target>`, `glass package <target>`.
4
+ Targets: desktop, ios, android. `--app-dir` selects the consumer directory;
5
+ `--url` explicitly overrides the configured URL. Use `--help` for arguments.
6
+
7
+ All project configuration and build orchestration lives here. Hosts receive
8
+ resolved files, and generated workspaces are independent of published runtime
9
+ artifacts. The CLI never installs dependencies or manages the web server.
10
+ See the root README for the configuration and build contracts.
package/bin/glass.mjs ADDED
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { parseArgs } from "node:util";
4
+ import { setupApp } from "../lib/assembly.mjs";
5
+ import { TARGETS } from "../lib/config.mjs";
6
+ import { nativeAction } from "../lib/workflow.mjs";
7
+
8
+ const usage = `Usage:
9
+ glass setup [--app-dir <directory>]
10
+ glass run <desktop|ios|android> [--app-dir <directory>] [--url <URL>] [-- <native arguments>]
11
+ glass package <desktop|ios|android> [--app-dir <directory>] [--url <HTTPS URL>] [--unsigned] [-- <desktop builder arguments>]`;
12
+ try {
13
+ const args = process.argv.slice(2);
14
+ const separator = args.indexOf("--");
15
+ const forwarded = separator < 0 ? [] : args.slice(separator + 1);
16
+ const { values, positionals } = parseArgs({
17
+ args: separator < 0 ? args : args.slice(0, separator),
18
+ allowPositionals: true,
19
+ strict: true,
20
+ options: {
21
+ "app-dir": { type: "string" },
22
+ url: { type: "string" },
23
+ unsigned: { type: "boolean" },
24
+ help: { type: "boolean", short: "h" },
25
+ },
26
+ });
27
+ if (values.help || args.length === 0) console.log(usage);
28
+ else {
29
+ const [action, target, ...extra] = positionals;
30
+ const appDirectory = path.resolve(values["app-dir"] ?? process.cwd());
31
+ if (
32
+ action === "setup" &&
33
+ !target &&
34
+ !extra.length &&
35
+ !values.url &&
36
+ !values.unsigned &&
37
+ !forwarded.length
38
+ )
39
+ console.log(
40
+ `Prepared native projects from ${await setupApp(appDirectory)}`,
41
+ );
42
+ else if (
43
+ ["run", "package"].includes(action) &&
44
+ TARGETS.includes(target) &&
45
+ !extra.length &&
46
+ !(action === "run" && values.unsigned)
47
+ ) {
48
+ if (target === "desktop" && values.unsigned)
49
+ throw new Error(
50
+ "Desktop signing uses CSC_NAME or CSC_LINK; omit --unsigned",
51
+ );
52
+ const host = await nativeAction(
53
+ appDirectory,
54
+ target,
55
+ action,
56
+ values,
57
+ forwarded,
58
+ );
59
+ if (action === "package") console.log(`Artifacts: ${host.artifacts}`);
60
+ } else throw new Error(usage);
61
+ }
62
+ } catch (error) {
63
+ console.error(error.message);
64
+ process.exitCode = 1;
65
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "appName": "My Glass App",
3
+ "appId": "com.example.myapp",
4
+ "buildNumber": 1,
5
+ "urls": {
6
+ "development": {
7
+ "desktop": "http://localhost:3000",
8
+ "ios": "http://localhost:3000",
9
+ "android": "http://10.0.2.2:3000"
10
+ }
11
+ }
12
+ }
@@ -0,0 +1,274 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createRequire } from "node:module";
4
+ import { withTargetLock } from "./lock.mjs";
5
+ import { readJSON, readApplication, resolveURL } from "./config.mjs";
6
+
7
+ export function installedPackage(appDirectory, name) {
8
+ const require = createRequire(path.join(appDirectory, "package.json"));
9
+ try {
10
+ return fs.realpathSync(
11
+ path.dirname(require.resolve(`${name}/package.json`)),
12
+ );
13
+ } catch {
14
+ throw new Error(
15
+ `Install ${name} in the application with your package manager`,
16
+ );
17
+ }
18
+ }
19
+ const xml = (value) =>
20
+ String(value)
21
+ .replaceAll("&", "&amp;")
22
+ .replaceAll("<", "&lt;")
23
+ .replaceAll(">", "&gt;")
24
+ .replaceAll('"', "&quot;")
25
+ .replaceAll("'", "&apos;");
26
+ const groovy = (value) =>
27
+ `'${String(value).replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
28
+
29
+ // Only SDK-owned files are synchronized. Native build caches and tools' generated
30
+ // files are never interpreted as templates or recursively deleted.
31
+ export function synchronize(directory, entries) {
32
+ fs.mkdirSync(directory, { recursive: true });
33
+ const statePath = path.join(directory, ".glass-source.json");
34
+ const previous = fs.existsSync(statePath) ? readJSON(statePath) : {};
35
+ for (const name of Object.keys(previous)) {
36
+ if (path.isAbsolute(name) || name.split(/[\\/]/u).includes(".."))
37
+ throw new Error("Invalid generated-source manifest");
38
+ if (!entries.has(name))
39
+ fs.rmSync(path.join(directory, name), { force: true });
40
+ }
41
+ const managed = {};
42
+ for (const [name, bytes] of entries) {
43
+ const file = path.join(directory, name);
44
+ const data = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
45
+ managed[name] = true;
46
+ if (fs.existsSync(file) && fs.readFileSync(file).equals(data)) continue;
47
+ fs.mkdirSync(path.dirname(file), { recursive: true });
48
+ fs.writeFileSync(file, data);
49
+ }
50
+ fs.writeFileSync(statePath, JSON.stringify(managed, null, 2) + "\n");
51
+ }
52
+ function collect(root, relative = "", entries = new Map()) {
53
+ for (const item of fs.readdirSync(path.join(root, relative), {
54
+ withFileTypes: true,
55
+ })) {
56
+ if (
57
+ [
58
+ "node_modules",
59
+ "build",
60
+ "dist",
61
+ "DerivedData",
62
+ "generated",
63
+ "Generated",
64
+ "xcuserdata",
65
+ ".gradle",
66
+ ".git",
67
+ ".DS_Store",
68
+ "local.properties",
69
+ ].includes(item.name)
70
+ )
71
+ continue;
72
+ const name = path.join(relative, item.name);
73
+ if (item.isDirectory()) collect(root, name, entries);
74
+ else if (item.isFile())
75
+ entries.set(name, fs.readFileSync(path.join(root, name)));
76
+ }
77
+ return entries;
78
+ }
79
+ function linkDependencies(source, destination) {
80
+ const require = createRequire(path.join(source, "package.json"));
81
+ for (const name of Object.keys(
82
+ readJSON(path.join(source, "package.json")).dependencies ?? {},
83
+ )) {
84
+ const resolved = path.dirname(require.resolve(`${name}/package.json`));
85
+ const link = path.join(destination, "node_modules", name);
86
+ fs.mkdirSync(path.dirname(link), { recursive: true });
87
+ if (
88
+ fs.existsSync(link) &&
89
+ fs.realpathSync(link) === fs.realpathSync(resolved)
90
+ )
91
+ continue;
92
+ fs.rmSync(link, { recursive: true, force: true });
93
+ fs.symlinkSync(resolved, link, "junction");
94
+ }
95
+ }
96
+ export function prepareHost(appDirectory, target, mode = "dev", override) {
97
+ const config = readApplication(appDirectory);
98
+ const url = resolveURL(config, target, mode, override);
99
+ const root = path.join(config.appDirectory, ".glass");
100
+ const directory = path.join(root, "hosts", target, mode);
101
+ const artifacts = path.join(
102
+ root,
103
+ "artifacts",
104
+ target,
105
+ `${config.version}-${config.buildNumber}`,
106
+ );
107
+ const source = installedPackage(
108
+ config.appDirectory,
109
+ target === "desktop" ? "@glass-sdk/host-desktop" : "@glass-sdk/host-mobile",
110
+ );
111
+ const capability = installedPackage(
112
+ config.appDirectory,
113
+ "@glass-sdk/file-picker",
114
+ );
115
+ const entries = new Map();
116
+ const addTree = (from, prefix = "") => {
117
+ for (const [name, data] of collect(from))
118
+ entries.set(path.join(prefix, name), data);
119
+ };
120
+ const defaultIcon =
121
+ target === "desktop"
122
+ ? path.join(source, "icon.png")
123
+ : path.join(
124
+ source,
125
+ "ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png",
126
+ );
127
+ const iconPath = config.icon
128
+ ? path.resolve(config.appDirectory, config.icon)
129
+ : defaultIcon;
130
+ const icon = fs.readFileSync(iconPath);
131
+ if (
132
+ icon.length < 24 ||
133
+ icon.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a" ||
134
+ icon.readUInt32BE(16) !== 1024 ||
135
+ icon.readUInt32BE(20) !== 1024
136
+ )
137
+ throw new Error("icon must be a 1024 × 1024 PNG");
138
+ const runtime = {
139
+ appName: config.appName,
140
+ appId: config.appId,
141
+ version: config.version,
142
+ buildNumber: config.buildNumber,
143
+ url,
144
+ };
145
+ const manifest = readJSON(path.join(source, "package.json"));
146
+ manifest.private = true;
147
+ delete manifest.bin;
148
+ if (target === "desktop") {
149
+ for (const name of [
150
+ "main.cjs",
151
+ "preload.cjs",
152
+ "navigation.cjs",
153
+ "electron-builder.config.cjs",
154
+ "error.html",
155
+ ])
156
+ entries.set(name, fs.readFileSync(path.join(source, name)));
157
+ entries.set(
158
+ "file-picker.cjs",
159
+ fs.readFileSync(path.join(capability, "native/desktop/file-picker.cjs")),
160
+ );
161
+ entries.set("icon.png", icon);
162
+ entries.set("runtime.json", JSON.stringify(runtime));
163
+ manifest.name = config.appId;
164
+ manifest.version = config.version;
165
+ manifest.devDependencies = manifest.dependencies;
166
+ delete manifest.dependencies;
167
+ } else {
168
+ addTree(path.join(source, target), target);
169
+ addTree(path.join(source, "web"), "web");
170
+ entries.set(
171
+ "web/error.html",
172
+ entries.get("web/error.html").toString().replace("{{APP_URL}}", xml(url)),
173
+ );
174
+ entries.set(
175
+ "capacitor.config.json",
176
+ JSON.stringify(
177
+ {
178
+ appId: config.appId,
179
+ appName: config.appName,
180
+ webDir: "web",
181
+ plugins: { SystemBars: { insetsHandling: "disable" } },
182
+ server: {
183
+ url: new URL(url).origin,
184
+ appStartPath:
185
+ target === "ios"
186
+ ? decodeURIComponent(new URL(url).pathname)
187
+ : new URL(url).pathname,
188
+ cleartext: mode === "dev" && url.startsWith("http:"),
189
+ errorPath: "error.html",
190
+ },
191
+ },
192
+ null,
193
+ 2,
194
+ ),
195
+ );
196
+ if (target === "ios") {
197
+ entries.set(
198
+ "ios/App/App/GlassFilePickerPlugin.swift",
199
+ fs.readFileSync(
200
+ path.join(capability, "native/ios/GlassFilePickerPlugin.swift"),
201
+ ),
202
+ );
203
+ entries.set(
204
+ "ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png",
205
+ icon,
206
+ );
207
+ entries.set(
208
+ "ios/App/App/Generated/GlassConfig.xcconfig",
209
+ `GLASS_IOS_BUNDLE_ID = ${config.appId}\nGLASS_APP_VERSION = ${config.nativeVersion}\nGLASS_BUILD_NUMBER = ${config.buildNumber}\n`,
210
+ );
211
+ entries.set(
212
+ "ios/App/App/Info.plist",
213
+ entries
214
+ .get("ios/App/App/Info.plist")
215
+ .toString()
216
+ .replaceAll("$(GLASS_APP_NAME)", xml(config.appName)),
217
+ );
218
+ } else {
219
+ entries.set(
220
+ "android/app/src/main/kotlin/com/glass/host/filepicker/GlassFilePickerPlugin.kt",
221
+ fs.readFileSync(
222
+ path.join(capability, "native/android/GlassFilePickerPlugin.kt"),
223
+ ),
224
+ );
225
+ entries.set(
226
+ "android/app/src/main/generated/kotlin/MainActivity.kt",
227
+ fs
228
+ .readFileSync(
229
+ path.join(source, "resources/android/MainActivity.kt.in"),
230
+ "utf8",
231
+ )
232
+ .replaceAll("{{PACKAGE_NAME}}", config.appId),
233
+ );
234
+ entries.set(
235
+ "android/app/src/main/generated/glass-config.gradle",
236
+ `ext.glassAppName = ${groovy(config.appName)}\next.glassAppId = ${groovy(config.appId)}\next.glassAppVersion = ${groovy(config.nativeVersion)}\next.glassBuildNumber = ${config.buildNumber}\n`,
237
+ );
238
+ entries.set("android/app/src/main/res/mipmap/ic_launcher.png", icon);
239
+ }
240
+ }
241
+ entries.set("package.json", JSON.stringify(manifest, null, 2));
242
+ synchronize(directory, entries);
243
+ if (target === "android")
244
+ fs.chmodSync(path.join(directory, "android/gradlew"), 0o755);
245
+ linkDependencies(source, directory);
246
+ return { config, directory, artifacts, source, root, runtime, target, mode };
247
+ }
248
+ export async function setupApp(appDirectory) {
249
+ const directory = path.resolve(appDirectory);
250
+ const manifest = readJSON(path.join(directory, "package.json"));
251
+ if (!manifest.name || !manifest.version)
252
+ throw new Error(
253
+ "Create an application with a package name and version first",
254
+ );
255
+ const configPath = path.join(directory, "glass.config.json");
256
+ if (!fs.existsSync(configPath))
257
+ fs.copyFileSync(
258
+ new URL("../glass.config.example.json", import.meta.url),
259
+ configPath,
260
+ );
261
+ const ignore = path.join(directory, ".gitignore");
262
+ const content = fs.existsSync(ignore) ? fs.readFileSync(ignore, "utf8") : "";
263
+ if (!content.split(/\r?\n/u).includes("/.glass/"))
264
+ fs.writeFileSync(
265
+ ignore,
266
+ `${content}${content.endsWith("\n") || !content ? "" : "\n"}/.glass/\n`,
267
+ );
268
+ const config = readApplication(directory);
269
+ for (const target of Object.keys(config.urls.development))
270
+ await withTargetLock(directory, target, "dev", () =>
271
+ prepareHost(directory, target),
272
+ );
273
+ return configPath;
274
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,115 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export const TARGETS = ["desktop", "ios", "android"];
5
+ function object(value, keys, label) {
6
+ if (!value || typeof value !== "object" || Array.isArray(value))
7
+ throw new Error(`${label} must be an object`);
8
+ for (const key of Object.keys(value))
9
+ if (!keys.includes(key))
10
+ throw new Error(`${label}.${key} is not supported`);
11
+ }
12
+ function text(value, label) {
13
+ if (typeof value !== "string" || !value.trim() || /[\x00-\x1f]/u.test(value))
14
+ throw new Error(`${label} must be nonempty, single-line text`);
15
+ }
16
+ export function appURL(value, release = false) {
17
+ let url;
18
+ try {
19
+ url = new URL(value);
20
+ } catch {
21
+ throw new Error("Application URL must be an absolute HTTP(S) URL");
22
+ }
23
+ if (
24
+ !["http:", "https:"].includes(url.protocol) ||
25
+ url.username ||
26
+ url.password
27
+ )
28
+ throw new Error("Application URL must be HTTP(S) without credentials");
29
+ if (url.search || url.hash)
30
+ throw new Error(
31
+ "Application URLs must not contain query parameters or fragments",
32
+ );
33
+ if (release && url.protocol !== "https:")
34
+ throw new Error("Release URLs must use HTTPS");
35
+ return url.href;
36
+ }
37
+ export function readJSON(file) {
38
+ try {
39
+ return JSON.parse(fs.readFileSync(file, "utf8"));
40
+ } catch (error) {
41
+ throw new Error(`Cannot read ${file}: ${error.message}`);
42
+ }
43
+ }
44
+ export function validateConfig(value) {
45
+ object(
46
+ value,
47
+ ["appName", "appId", "buildNumber", "urls", "icon", "ios"],
48
+ "glass.config.json",
49
+ );
50
+ text(value.appName, "appName");
51
+ if (!/^[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)+$/u.test(value.appId))
52
+ throw new Error(
53
+ "appId must be a reverse-domain identifier, for example com.example.myapp",
54
+ );
55
+ if (
56
+ !Number.isSafeInteger(value.buildNumber) ||
57
+ value.buildNumber < 1 ||
58
+ value.buildNumber > 2100000000
59
+ )
60
+ throw new Error("buildNumber must be an integer from 1 to 2100000000");
61
+ object(value.urls, ["development", "release"], "urls");
62
+ object(value.urls.development, TARGETS, "urls.development");
63
+ if (Object.keys(value.urls.development).length === 0)
64
+ throw new Error("Configure at least one development target");
65
+ for (const url of Object.values(value.urls.development)) appURL(url);
66
+ if (value.urls.release !== undefined) appURL(value.urls.release, true);
67
+ if (value.icon !== undefined) text(value.icon, "icon");
68
+ if (value.ios !== undefined) {
69
+ object(value.ios, ["teamId", "exportOptions"], "ios");
70
+ if (
71
+ value.ios.teamId !== undefined &&
72
+ !/^[A-Z0-9]{10}$/u.test(value.ios.teamId)
73
+ )
74
+ throw new Error("ios.teamId must be a 10-character Apple team ID");
75
+ if (value.ios.exportOptions !== undefined)
76
+ text(value.ios.exportOptions, "ios.exportOptions");
77
+ }
78
+ return value;
79
+ }
80
+ export function readApplication(directory) {
81
+ const appDirectory = path.resolve(directory);
82
+ const manifest = readJSON(path.join(appDirectory, "package.json"));
83
+ text(manifest.name, "package.json name");
84
+ const version =
85
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u.exec(
86
+ manifest.version,
87
+ );
88
+ if (!version)
89
+ throw new Error("package.json version must be a semantic version");
90
+ const config = validateConfig(
91
+ readJSON(path.join(appDirectory, "glass.config.json")),
92
+ );
93
+ return {
94
+ ...config,
95
+ appDirectory,
96
+ version: manifest.version,
97
+ nativeVersion: version.slice(1, 4).join("."),
98
+ };
99
+ }
100
+ export function resolveURL(config, target, mode, override) {
101
+ if (!TARGETS.includes(target))
102
+ throw new Error(`Choose ${TARGETS.join(", ")}`);
103
+ if (!["dev", "release"].includes(mode))
104
+ throw new Error("Mode must be dev or release");
105
+ const url =
106
+ override ??
107
+ (mode === "release"
108
+ ? config.urls.release
109
+ : config.urls.development[target]);
110
+ if (!url)
111
+ throw new Error(
112
+ `Configure ${mode === "release" ? "urls.release" : `urls.development.${target}`} or pass --url`,
113
+ );
114
+ return appURL(url, mode === "release");
115
+ }
package/lib/java.mjs ADDED
@@ -0,0 +1,36 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+
5
+ export function environmentForGradle(environment = process.env) {
6
+ if (!environment.JAVA_HOME)
7
+ throw new Error(
8
+ "Set JAVA_HOME to a JDK 21–24 installation before building Android",
9
+ );
10
+ const home = path.resolve(environment.JAVA_HOME);
11
+ const java = path.join(
12
+ home,
13
+ "bin",
14
+ process.platform === "win32" ? "java.exe" : "java",
15
+ );
16
+ if (!fs.existsSync(java))
17
+ throw new Error(`JAVA_HOME does not contain a Java executable: ${home}`);
18
+ const result = spawnSync(java, ["-version"], { encoding: "utf8" });
19
+ const version = /version\s+"(\d+)/u.exec(
20
+ `${result.stdout ?? ""}\n${result.stderr ?? ""}`,
21
+ )?.[1];
22
+ if (
23
+ result.status !== 0 ||
24
+ !version ||
25
+ Number(version) < 21 ||
26
+ Number(version) > 24
27
+ )
28
+ throw new Error(
29
+ "Glass Android requires JDK 21–24 with Gradle 8.14.3; update JAVA_HOME",
30
+ );
31
+ return {
32
+ ...environment,
33
+ JAVA_HOME: home,
34
+ PATH: `${path.join(home, "bin")}${path.delimiter}${environment.PATH ?? ""}`,
35
+ };
36
+ }
package/lib/lock.mjs ADDED
@@ -0,0 +1,24 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ export async function withTargetLock(appDirectory, target, mode, action) {
4
+ const locks = path.join(path.resolve(appDirectory), ".glass", "locks");
5
+ fs.mkdirSync(locks, { recursive: true });
6
+ const lock = path.join(locks, `${target}-${mode}`);
7
+ try {
8
+ fs.mkdirSync(lock);
9
+ } catch (error) {
10
+ if (error.code !== "EEXIST") throw error;
11
+ throw new Error(
12
+ `Another ${target} ${mode} operation owns ${lock}. If it was terminated, remove this lock before retrying.`,
13
+ );
14
+ }
15
+ fs.writeFileSync(
16
+ path.join(lock, "owner.json"),
17
+ JSON.stringify({ pid: process.pid }),
18
+ );
19
+ try {
20
+ return await action();
21
+ } finally {
22
+ fs.rmSync(lock, { recursive: true, force: true });
23
+ }
24
+ }
@@ -0,0 +1,181 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { createRequire } from "node:module";
5
+ import { prepareHost } from "./assembly.mjs";
6
+ import { withTargetLock } from "./lock.mjs";
7
+ export { withTargetLock } from "./lock.mjs";
8
+ import { environmentForGradle } from "./java.mjs";
9
+
10
+ export function run(command, args, { cwd, env = process.env } = {}) {
11
+ return new Promise((resolve, reject) => {
12
+ const child = spawn(command, args, {
13
+ cwd,
14
+ env,
15
+ stdio: "inherit",
16
+ shell: process.platform === "win32" && command.endsWith(".bat"),
17
+ });
18
+ const interrupt = () => child.kill("SIGINT");
19
+ const terminate = () => child.kill("SIGTERM");
20
+ process.on("SIGINT", interrupt);
21
+ process.on("SIGTERM", terminate);
22
+ const cleanup = () => {
23
+ process.off("SIGINT", interrupt);
24
+ process.off("SIGTERM", terminate);
25
+ };
26
+ child.once("error", (error) => {
27
+ cleanup();
28
+ reject(error);
29
+ });
30
+ child.once("exit", (code, signal) => {
31
+ cleanup();
32
+ code === 0
33
+ ? resolve()
34
+ : reject(
35
+ new Error(
36
+ `${path.basename(command)} exited with ${signal ?? code}`,
37
+ ),
38
+ );
39
+ });
40
+ });
41
+ }
42
+ export async function nativeAction(
43
+ appDirectory,
44
+ target,
45
+ action,
46
+ options = {},
47
+ forwarded = [],
48
+ ) {
49
+ const mode = action === "run" ? "dev" : "release";
50
+ if (target === "ios" && process.platform !== "darwin")
51
+ throw new Error("iOS requires macOS and Xcode");
52
+ return withTargetLock(appDirectory, target, mode, async () => {
53
+ const host = prepareHost(appDirectory, target, mode, options.url);
54
+ const require = createRequire(path.join(host.source, "package.json"));
55
+ const context = { cwd: host.directory };
56
+ if (target === "desktop") {
57
+ if (action === "run") {
58
+ await run(require("electron"), [host.directory, ...forwarded], context);
59
+ } else {
60
+ await run(
61
+ process.execPath,
62
+ [
63
+ require.resolve("electron-builder/cli.js"),
64
+ "--projectDir",
65
+ host.directory,
66
+ "--config",
67
+ path.join(host.directory, "electron-builder.config.cjs"),
68
+ ...forwarded,
69
+ ],
70
+ { ...context, env: { ...process.env, GLASS_OUTPUT: host.artifacts } },
71
+ );
72
+ }
73
+ return host;
74
+ }
75
+ if (target === "android") context.env = environmentForGradle(process.env);
76
+ const capacitor = require.resolve("@capacitor/cli/bin/capacitor");
77
+ if (action === "run") {
78
+ await run(
79
+ process.execPath,
80
+ [capacitor, "run", target, ...forwarded],
81
+ context,
82
+ );
83
+ return host;
84
+ }
85
+ if (forwarded.length)
86
+ throw new Error(
87
+ "Mobile packaging does not accept forwarded arguments; use --unsigned or ios.exportOptions",
88
+ );
89
+ if (
90
+ target === "ios" &&
91
+ !options.unsigned &&
92
+ (!host.config.ios?.teamId || !host.config.ios?.exportOptions)
93
+ )
94
+ throw new Error(
95
+ "Signed iOS packaging requires ios.teamId and ios.exportOptions; use --unsigned for a build-only archive",
96
+ );
97
+ if (target === "android" && !options.unsigned) {
98
+ for (const key of [
99
+ "GLASS_ANDROID_KEYSTORE",
100
+ "GLASS_ANDROID_STORE_PASSWORD",
101
+ "GLASS_ANDROID_KEY_ALIAS",
102
+ "GLASS_ANDROID_KEY_PASSWORD",
103
+ ])
104
+ if (!process.env[key])
105
+ throw new Error(
106
+ `Signed Android packaging requires ${key}; use --unsigned for a build-only bundle`,
107
+ );
108
+ }
109
+ await run(process.execPath, [capacitor, "sync", target], context);
110
+ fs.mkdirSync(host.artifacts, { recursive: true });
111
+ if (target === "ios") {
112
+ const archive = path.join(host.artifacts, "App.xcarchive");
113
+ await run(
114
+ "xcodebuild",
115
+ [
116
+ "-project",
117
+ "ios/App/App.xcodeproj",
118
+ "-scheme",
119
+ "App",
120
+ "-configuration",
121
+ "Release",
122
+ "-destination",
123
+ "generic/platform=iOS",
124
+ "-derivedDataPath",
125
+ path.join(host.directory, "DerivedData"),
126
+ "-archivePath",
127
+ archive,
128
+ ...(options.unsigned
129
+ ? ["CODE_SIGNING_ALLOWED=NO"]
130
+ : [`DEVELOPMENT_TEAM=${host.config.ios.teamId}`]),
131
+ "archive",
132
+ ],
133
+ context,
134
+ );
135
+ if (!options.unsigned)
136
+ await run(
137
+ "xcodebuild",
138
+ [
139
+ "-exportArchive",
140
+ "-archivePath",
141
+ archive,
142
+ "-exportPath",
143
+ host.artifacts,
144
+ "-exportOptionsPlist",
145
+ path.resolve(
146
+ host.config.appDirectory,
147
+ host.config.ios.exportOptions,
148
+ ),
149
+ ],
150
+ context,
151
+ );
152
+ } else {
153
+ const env = {
154
+ ...context.env,
155
+ GLASS_UNSIGNED: options.unsigned ? "1" : "0",
156
+ };
157
+ if (env.GLASS_ANDROID_KEYSTORE)
158
+ env.GLASS_ANDROID_KEYSTORE = path.resolve(
159
+ host.config.appDirectory,
160
+ env.GLASS_ANDROID_KEYSTORE,
161
+ );
162
+ await run(
163
+ process.platform === "win32" ? "gradlew.bat" : "sh",
164
+ [
165
+ ...(process.platform === "win32" ? [] : ["./gradlew"]),
166
+ "--no-daemon",
167
+ ":app:bundleRelease",
168
+ ],
169
+ { cwd: path.join(host.directory, "android"), env },
170
+ );
171
+ fs.copyFileSync(
172
+ path.join(
173
+ host.directory,
174
+ "android/app/build/outputs/bundle/release/app-release.aab",
175
+ ),
176
+ path.join(host.artifacts, "app-release.aab"),
177
+ );
178
+ }
179
+ return host;
180
+ });
181
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@glass-sdk/cli",
3
+ "version": "0.0.1",
4
+ "description": "The Glass consumer command for preparing, running, and packaging native app targets.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/Glass-HQ/Glass-SDK.git",
8
+ "directory": "packages/cli"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "type": "module",
14
+ "files": [
15
+ "bin",
16
+ "lib",
17
+ "glass.config.example.json",
18
+ "CHANGELOG.md",
19
+ "README.md"
20
+ ],
21
+ "scripts": {
22
+ "build": "node --check bin/glass.mjs"
23
+ },
24
+ "dependencies": {
25
+ "@glass-sdk/host-desktop": "workspace:*",
26
+ "@glass-sdk/host-mobile": "workspace:*",
27
+ "@glass-sdk/file-picker": "workspace:*"
28
+ },
29
+ "engines": {
30
+ "node": ">=22.12.0"
31
+ },
32
+ "bin": {
33
+ "glass": "bin/glass.mjs"
34
+ }
35
+ }