@cerebral-work/lizard 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/README.md +65 -0
- package/bin/lizard-install.mjs +10 -0
- package/lib/lizard-install.d.ts +36 -0
- package/lib/lizard-install.mjs +266 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# @cerebral-work/lizard
|
|
2
|
+
|
|
3
|
+
Installs [lizard](https://github.com/unsigned-gg/lizard), the estate onboarding
|
|
4
|
+
and control TUI, from its GitHub releases.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npx @cerebral-work/lizard # the latest release, into ~/.local/bin
|
|
8
|
+
npx @cerebral-work/lizard --release v0.5.0 # a specific release
|
|
9
|
+
npx @cerebral-work/lizard --bin-dir ~/bin # somewhere else
|
|
10
|
+
npx @cerebral-work/lizard --dry-run # show what would happen
|
|
11
|
+
npx @cerebral-work/lizard status # installed vs latest; exit 1 if they differ
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Then run `lizard`.
|
|
15
|
+
|
|
16
|
+
## From code
|
|
17
|
+
|
|
18
|
+
The `@cerebral-work/install` wizard imports this package rather than running
|
|
19
|
+
it. `install()` and `status()` return the same `{ lines, conflicts }` report as
|
|
20
|
+
`@cerebral-work/skills`. Both are async, so a caller can keep its UI moving
|
|
21
|
+
during a download, and both reject with `InstallError`, whose message is meant
|
|
22
|
+
to be shown as is.
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
import { install, status } from "@cerebral-work/lizard";
|
|
26
|
+
|
|
27
|
+
await status({ home }); // conflicts: 1 if missing or out of date
|
|
28
|
+
await install({ home, dryRun: true }); // { lines: ["would install lizard v0.5.0 ..."], conflicts: 0 }
|
|
29
|
+
await install({ home }); // also takes release and binDir
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Types ship in `lib/lizard-install.d.ts`.
|
|
33
|
+
|
|
34
|
+
## Who can install it
|
|
35
|
+
|
|
36
|
+
This package is public, but lizard is not. The installer downloads the release
|
|
37
|
+
with your own `gh` login, so it works only if that login can read
|
|
38
|
+
`unsigned-gg/lizard`. Without access, nothing is installed.
|
|
39
|
+
|
|
40
|
+
## What it does
|
|
41
|
+
|
|
42
|
+
1. Picks the build for this machine: Linux x86_64, macOS Intel or macOS Apple
|
|
43
|
+
Silicon. On native Windows, run it from WSL. aarch64 Linux has no build yet.
|
|
44
|
+
2. Checks that `gh` is logged in, then reads the release.
|
|
45
|
+
3. Downloads the tarball and checks its SHA-256 against the digest GitHub
|
|
46
|
+
publishes for that file. A mismatch, or a release with no digest, stops the
|
|
47
|
+
install before anything is written.
|
|
48
|
+
4. Installs `lizard` into the bin directory, replacing an older copy, and runs
|
|
49
|
+
`lizard --version` from there.
|
|
50
|
+
|
|
51
|
+
`LIZARD_VERSION` and `LIZARD_BIN_DIR` work the same as `--release` and
|
|
52
|
+
`--bin-dir`, and match `scripts/install-lizard.sh`.
|
|
53
|
+
|
|
54
|
+
## Requirements
|
|
55
|
+
|
|
56
|
+
Node 22 or later, [`gh`](https://cli.github.com), and `tar`.
|
|
57
|
+
|
|
58
|
+
## Publishing
|
|
59
|
+
|
|
60
|
+
Not automated yet. This package is versioned separately from lizard:
|
|
61
|
+
release-please skips commits that touch only `npm/`. A PR that also touches
|
|
62
|
+
other files is counted by its merge commit, so give it a title that doesn't
|
|
63
|
+
bump the version, such as `build(npm): ...`. To ship a change, bump `version` in
|
|
64
|
+
`package.json`, check the file list with `npm pack --dry-run`, then run
|
|
65
|
+
`npm publish` from `npm/`.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { InstallError, main } from "../lib/lizard-install.mjs";
|
|
3
|
+
|
|
4
|
+
try {
|
|
5
|
+
process.exitCode = await main();
|
|
6
|
+
} catch (e) {
|
|
7
|
+
if (!(e instanceof InstallError)) throw e;
|
|
8
|
+
console.error(`lizard-install: ${e.message}`);
|
|
9
|
+
process.exitCode = 1;
|
|
10
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** The same report shape as @cerebral-work/skills. */
|
|
2
|
+
export interface Report {
|
|
3
|
+
lines: string[];
|
|
4
|
+
conflicts: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface InstallOptions {
|
|
8
|
+
/** Default: the current user's home directory. */
|
|
9
|
+
home?: string;
|
|
10
|
+
/** Resolve the release and report what would change, writing nothing. */
|
|
11
|
+
dryRun?: boolean;
|
|
12
|
+
/** A release tag such as "v0.5.0". Default: the latest release. */
|
|
13
|
+
release?: string;
|
|
14
|
+
/** Default: `<home>/.local/bin`. */
|
|
15
|
+
binDir?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type StatusOptions = Omit<InstallOptions, "dryRun">;
|
|
19
|
+
|
|
20
|
+
/** Thrown for every expected failure. The message is meant for the user. */
|
|
21
|
+
export class InstallError extends Error {}
|
|
22
|
+
|
|
23
|
+
export const REPO: string;
|
|
24
|
+
export const USAGE: string;
|
|
25
|
+
|
|
26
|
+
/** The release asset's Rust target triple for a Node platform and arch. */
|
|
27
|
+
export function targetFor(platform: string, arch: string): string;
|
|
28
|
+
|
|
29
|
+
/** Installs lizard; conflicts is always 0. Rejects with InstallError. */
|
|
30
|
+
export function install(opts?: InstallOptions): Promise<Report>;
|
|
31
|
+
|
|
32
|
+
/** conflicts is 1 when lizard is missing or not the release's version. */
|
|
33
|
+
export function status(opts?: StatusOptions): Promise<Report>;
|
|
34
|
+
|
|
35
|
+
/** The CLI. Resolves to the process exit code. */
|
|
36
|
+
export function main(argv?: string[], env?: Record<string, string | undefined>): Promise<number>;
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// lizard-install: puts the unsigned-gg/lizard binary on this machine.
|
|
2
|
+
//
|
|
3
|
+
// The lizard repo is private, so this package holds no binary. The release
|
|
4
|
+
// tarball is downloaded with the user's own `gh` login and checked against the
|
|
5
|
+
// SHA-256 digest GitHub publishes for it before anything is installed.
|
|
6
|
+
//
|
|
7
|
+
// install() and status() return the same { lines, conflicts } report as
|
|
8
|
+
// @cerebral-work/skills, so the @cerebral-work/install wizard can treat every
|
|
9
|
+
// component alike. They are async, so a caller can keep animating while a
|
|
10
|
+
// download runs. Failures reject with InstallError, whose message is meant to
|
|
11
|
+
// be shown to the user as is.
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { chmod, copyFile, mkdir, mkdtemp, readFile, rename, rm } from "node:fs/promises";
|
|
16
|
+
import { homedir, tmpdir } from "node:os";
|
|
17
|
+
import { delimiter, join, resolve } from "node:path";
|
|
18
|
+
import { parseArgs } from "node:util";
|
|
19
|
+
|
|
20
|
+
export const REPO = "unsigned-gg/lizard";
|
|
21
|
+
|
|
22
|
+
export const USAGE = `usage: npx @cerebral-work/lizard [status] [options]
|
|
23
|
+
|
|
24
|
+
Installs lizard from the ${REPO} GitHub releases.
|
|
25
|
+
With status, reports the installed and latest versions; exits 1 if they differ.
|
|
26
|
+
|
|
27
|
+
--release <tag> install this release (default: the latest), e.g. v0.5.0
|
|
28
|
+
--bin-dir <dir> install into <dir> (default: ~/.local/bin)
|
|
29
|
+
--dry-run resolve the release and print what would change
|
|
30
|
+
-h, --help show this help
|
|
31
|
+
|
|
32
|
+
Env: LIZARD_VERSION (same as --release), LIZARD_BIN_DIR (same as --bin-dir).
|
|
33
|
+
Needs gh, logged in with read access to ${REPO}.`;
|
|
34
|
+
|
|
35
|
+
// process.platform-process.arch -> Rust target triple of the release asset.
|
|
36
|
+
const TARGETS = {
|
|
37
|
+
"linux-x64": "x86_64-unknown-linux-gnu",
|
|
38
|
+
"darwin-x64": "x86_64-apple-darwin",
|
|
39
|
+
"darwin-arm64": "aarch64-apple-darwin",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export class InstallError extends Error {}
|
|
43
|
+
|
|
44
|
+
export function targetFor(platform, arch) {
|
|
45
|
+
const target = TARGETS[`${platform}-${arch}`];
|
|
46
|
+
if (target) return target;
|
|
47
|
+
if (platform === "win32") {
|
|
48
|
+
throw new InstallError("lizard has no native Windows build. Run this from WSL instead.");
|
|
49
|
+
}
|
|
50
|
+
if (platform === "linux" && arch === "arm64") {
|
|
51
|
+
throw new InstallError(
|
|
52
|
+
"lizard has no aarch64 Linux build yet. Build it from a checkout of unsigned-gg/lizard: cargo install --path .",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
throw new InstallError(`lizard has no build for ${platform}-${arch}.`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Resolves { status, stdout, stderr, error }. status is the exit code, or null
|
|
59
|
+
// when the process never ran (error is then set) or was killed by a signal.
|
|
60
|
+
function run(cmd, args) {
|
|
61
|
+
return new Promise((done) => {
|
|
62
|
+
execFile(cmd, args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
63
|
+
if (err && typeof err.code !== "number") {
|
|
64
|
+
done({ status: null, stdout, stderr, error: err });
|
|
65
|
+
} else {
|
|
66
|
+
done({ status: err ? err.code : 0, stdout, stderr, error: null });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function gh(args) {
|
|
73
|
+
const r = await run("gh", args);
|
|
74
|
+
if (r.error?.code === "ENOENT") {
|
|
75
|
+
throw new InstallError("the GitHub CLI (gh) is required: https://cli.github.com");
|
|
76
|
+
}
|
|
77
|
+
if (r.error) throw r.error;
|
|
78
|
+
return r;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function requireGhLogin() {
|
|
82
|
+
if ((await gh(["auth", "status"])).status !== 0) {
|
|
83
|
+
throw new InstallError("gh is not logged in. Run: gh auth login");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Reads the release once, for both its tag and the asset's digest. A release
|
|
88
|
+
// without a digest for the asset is refused rather than installed unverified.
|
|
89
|
+
async function resolveRelease(tag, assetName) {
|
|
90
|
+
const r = await gh(["release", "view", ...(tag ? [tag] : []), "--repo", REPO, "--json", "tagName,assets"]);
|
|
91
|
+
if (r.status !== 0) {
|
|
92
|
+
throw new InstallError(
|
|
93
|
+
`could not read ${tag ? `release ${tag}` : "the latest release"} of ${REPO}: ${r.stderr.trim()}\n` +
|
|
94
|
+
`If the release exists, your gh login needs read access to ${REPO}.`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
const release = JSON.parse(r.stdout);
|
|
98
|
+
const asset = release.assets.find((a) => a.name === assetName);
|
|
99
|
+
if (!asset) {
|
|
100
|
+
throw new InstallError(`release ${release.tagName} has no ${assetName}.`);
|
|
101
|
+
}
|
|
102
|
+
const digest = /^sha256:([0-9a-f]{64})$/.exec(asset.digest ?? "");
|
|
103
|
+
if (!digest) {
|
|
104
|
+
throw new InstallError(
|
|
105
|
+
`release ${release.tagName} publishes no SHA-256 digest for ${assetName}, so it can't be verified. Not installing.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return { tag: release.tagName, sha256: digest[1] };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function locate({ home = homedir(), binDir } = {}) {
|
|
112
|
+
const dir = resolve(binDir || join(home, ".local", "bin"));
|
|
113
|
+
return { binDir: dir, dest: join(dir, "lizard") };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Runs the binary by absolute path, never by name: by name, PATH could
|
|
117
|
+
// resolve to some other lizard than the one at `dest`.
|
|
118
|
+
async function installedVersion(dest) {
|
|
119
|
+
if (!existsSync(dest)) return null;
|
|
120
|
+
const v = await run(dest, ["--version"]);
|
|
121
|
+
if (v.status !== 0) return null;
|
|
122
|
+
return v.stdout.trim();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const sameVersion = (installed, tag) => installed?.split(/\s+/).pop() === tag.replace(/^v/, "");
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Installs lizard into `binDir` (default: `<home>/.local/bin`), replacing an
|
|
129
|
+
* older copy. `release` pins a tag; the default is the latest release.
|
|
130
|
+
*/
|
|
131
|
+
export async function install({ home, dryRun = false, release, binDir } = {}) {
|
|
132
|
+
const report = { lines: [], conflicts: 0 };
|
|
133
|
+
const target = targetFor(process.platform, process.arch);
|
|
134
|
+
const assetName = `lizard-${target}.tar.gz`;
|
|
135
|
+
const loc = locate({ home, binDir });
|
|
136
|
+
|
|
137
|
+
await requireGhLogin();
|
|
138
|
+
const rel = await resolveRelease(release, assetName);
|
|
139
|
+
|
|
140
|
+
if (dryRun) {
|
|
141
|
+
report.lines.push(`would install lizard ${rel.tag} (${target}) to ${loc.dest}`);
|
|
142
|
+
return report;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const tmp = await mkdtemp(join(tmpdir(), "lizard-install-"));
|
|
146
|
+
try {
|
|
147
|
+
const dl = await gh(["release", "download", rel.tag, "--repo", REPO, "--pattern", assetName, "--dir", tmp]);
|
|
148
|
+
if (dl.status !== 0) {
|
|
149
|
+
throw new InstallError(`could not download ${assetName}:\n${dl.stderr.trim()}`);
|
|
150
|
+
}
|
|
151
|
+
const tarball = join(tmp, assetName);
|
|
152
|
+
const actual = createHash("sha256").update(await readFile(tarball)).digest("hex");
|
|
153
|
+
if (actual !== rel.sha256) {
|
|
154
|
+
throw new InstallError(
|
|
155
|
+
`checksum mismatch for ${assetName}: expected ${rel.sha256}, got ${actual}. Not installing.`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const tar = await run("tar", ["-xzf", tarball, "-C", tmp]);
|
|
160
|
+
if (tar.status !== 0) {
|
|
161
|
+
throw new InstallError(`could not unpack ${assetName}: ${tar.error?.message ?? tar.stderr.trim()}`);
|
|
162
|
+
}
|
|
163
|
+
const extracted = join(tmp, "lizard");
|
|
164
|
+
if (!existsSync(extracted)) {
|
|
165
|
+
throw new InstallError(`${assetName} has no lizard binary in it.`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Stage next to the target, then rename over it: a running lizard keeps
|
|
169
|
+
// its old inode instead of the copy failing with "text file busy".
|
|
170
|
+
await mkdir(loc.binDir, { recursive: true });
|
|
171
|
+
const staged = `${loc.dest}.${process.pid}.tmp`;
|
|
172
|
+
try {
|
|
173
|
+
await copyFile(extracted, staged);
|
|
174
|
+
await chmod(staged, 0o755);
|
|
175
|
+
await rename(staged, loc.dest);
|
|
176
|
+
} catch (e) {
|
|
177
|
+
await rm(staged, { force: true });
|
|
178
|
+
throw e;
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
await rm(tmp, { recursive: true, force: true });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const version = await installedVersion(loc.dest);
|
|
185
|
+
if (!version) {
|
|
186
|
+
throw new InstallError(`installed ${loc.dest}, but it did not run.`);
|
|
187
|
+
}
|
|
188
|
+
report.lines.push(`installed ${version} (${rel.tag}, ${target}) to ${loc.dest}`);
|
|
189
|
+
|
|
190
|
+
if (process.platform === "darwin") {
|
|
191
|
+
report.lines.push(`note: the binary is unsigned. If Gatekeeper blocks it: xattr -d com.apple.quarantine ${loc.dest}`);
|
|
192
|
+
}
|
|
193
|
+
const onPath = (process.env.PATH ?? "")
|
|
194
|
+
.split(delimiter)
|
|
195
|
+
.filter(Boolean)
|
|
196
|
+
.some((p) => resolve(p) === loc.binDir);
|
|
197
|
+
if (!onPath) {
|
|
198
|
+
report.lines.push(`note: ${loc.binDir} is not on your PATH`);
|
|
199
|
+
}
|
|
200
|
+
return report;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Compares the lizard in `binDir` with the latest release (or `release`).
|
|
205
|
+
* conflicts is 1 when lizard is missing or a different version, else 0.
|
|
206
|
+
*/
|
|
207
|
+
export async function status({ home, release, binDir } = {}) {
|
|
208
|
+
const report = { lines: [], conflicts: 0 };
|
|
209
|
+
const target = targetFor(process.platform, process.arch);
|
|
210
|
+
const loc = locate({ home, binDir });
|
|
211
|
+
|
|
212
|
+
await requireGhLogin();
|
|
213
|
+
const rel = await resolveRelease(release, `lizard-${target}.tar.gz`);
|
|
214
|
+
const version = await installedVersion(loc.dest);
|
|
215
|
+
|
|
216
|
+
if (!version) {
|
|
217
|
+
report.lines.push(`lizard is not installed at ${loc.dest} (${rel.tag} is available)`);
|
|
218
|
+
report.conflicts = 1;
|
|
219
|
+
} else if (!sameVersion(version, rel.tag)) {
|
|
220
|
+
report.lines.push(`${version} at ${loc.dest}; ${rel.tag} is available`);
|
|
221
|
+
report.conflicts = 1;
|
|
222
|
+
} else {
|
|
223
|
+
report.lines.push(`${version} at ${loc.dest} is current`);
|
|
224
|
+
}
|
|
225
|
+
return report;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The CLI. Resolves to the process exit code. */
|
|
229
|
+
export async function main(argv = process.argv.slice(2), env = process.env) {
|
|
230
|
+
// `npx @cerebral-work/lizard -- --dry-run` forwards the `--` itself.
|
|
231
|
+
const args = argv[0] === "--" ? argv.slice(1) : argv;
|
|
232
|
+
let parsed;
|
|
233
|
+
try {
|
|
234
|
+
parsed = parseArgs({
|
|
235
|
+
args,
|
|
236
|
+
allowPositionals: true,
|
|
237
|
+
options: {
|
|
238
|
+
release: { type: "string" },
|
|
239
|
+
"bin-dir": { type: "string" },
|
|
240
|
+
"dry-run": { type: "boolean" },
|
|
241
|
+
help: { type: "boolean", short: "h" },
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
} catch (e) {
|
|
245
|
+
throw new InstallError(`${e.message}\n\n${USAGE}`);
|
|
246
|
+
}
|
|
247
|
+
const { values: opts, positionals } = parsed;
|
|
248
|
+
if (opts.help) {
|
|
249
|
+
console.log(USAGE);
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
if (positionals.length > 1 || (positionals.length === 1 && positionals[0] !== "status")) {
|
|
253
|
+
throw new InstallError(`unknown command: ${positionals.join(" ")}\n\n${USAGE}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const common = {
|
|
257
|
+
release: opts.release || env.LIZARD_VERSION || undefined,
|
|
258
|
+
binDir: opts["bin-dir"] || env.LIZARD_BIN_DIR || undefined,
|
|
259
|
+
};
|
|
260
|
+
const report =
|
|
261
|
+
positionals[0] === "status"
|
|
262
|
+
? await status(common)
|
|
263
|
+
: await install({ ...common, dryRun: Boolean(opts["dry-run"]) });
|
|
264
|
+
for (const line of report.lines) console.log(line);
|
|
265
|
+
return report.conflicts > 0 ? 1 : 0;
|
|
266
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cerebral-work/lizard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Installer for lizard, the estate onboarding and control TUI. Downloads the release binary with your own gh login and verifies its SHA-256.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/unsigned-gg/lizard.git",
|
|
9
|
+
"directory": "npm"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./lib/lizard-install.d.ts",
|
|
15
|
+
"default": "./lib/lizard-install.mjs"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"types": "./lib/lizard-install.d.ts",
|
|
19
|
+
"bin": {
|
|
20
|
+
"lizard-install": "bin/lizard-install.mjs"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"bin/",
|
|
24
|
+
"lib/"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test test/*.test.mjs"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
}
|
|
35
|
+
}
|