@memohai/cloud-runtime 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/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@memohai/cloud-runtime",
3
+ "version": "0.1.0",
4
+ "description": "Memoh Cloud Remote Runtime CLI — connects a local machine to Memoh Cloud",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/memohai/Memoh-Cloud.git",
8
+ "directory": "packages/cloud-runtime"
9
+ },
10
+ "license": "AGPL-3.0-only",
11
+ "type": "module",
12
+ "bin": {
13
+ "memoh-cloud-runtime": "dist/cli.mjs"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "src",
18
+ "scripts"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "engines": {
24
+ "node": ">=20"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^25.9.5",
28
+ "esbuild": "0.27.2",
29
+ "typescript": "^6.0.3",
30
+ "vitest": "^4.1.10"
31
+ },
32
+ "scripts": {
33
+ "build": "node scripts/build.mjs",
34
+ "test": "vitest run",
35
+ "typecheck": "tsc --noEmit"
36
+ }
37
+ }
@@ -0,0 +1,191 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { access, chmod, copyFile, mkdir, readFile, rm } from 'node:fs/promises'
3
+ import { dirname, resolve } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ import { build } from 'esbuild'
7
+
8
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
9
+ const submodule = resolve(root, '../memoh')
10
+ const upstreamRuntime = resolve(submodule, 'packages/runtime')
11
+ const protoSource = resolve(submodule, 'internal/workspace/bridgepb/bridge.proto')
12
+ const outdir = resolve(root, 'dist')
13
+
14
+ // This package ships no runtime dependencies: the entire implementation is
15
+ // bundled from the memoh submodule. Node resolves the upstream sources' own
16
+ // imports (ws, @grpc/*) against packages/memoh's node_modules — resolution
17
+ // walks up from the importing file, not from this package — so the submodule
18
+ // has to be both checked out and installed.
19
+ await requirePath(
20
+ resolve(upstreamRuntime, 'src/cli.ts'),
21
+ 'packages/memoh submodule is not checked out — run `git submodule update --init --recursive`',
22
+ )
23
+ await requirePath(
24
+ resolve(upstreamRuntime, 'node_modules/ws'),
25
+ 'packages/memoh is not installed — run `pnpm install --filter @memohai/runtime...` from packages/memoh',
26
+ )
27
+ await requirePath(
28
+ protoSource,
29
+ 'canonical bridge.proto is missing from the memoh submodule',
30
+ )
31
+
32
+ // Which upstream runtime ends up inside the bundle is decided by the submodule
33
+ // gitlink, and nothing in the published tarball would otherwise record it.
34
+ // Stamp it in so a build in the wild can identify itself — see src/build-info.ts.
35
+ const cloudVersion = await readVersion(resolve(root, 'package.json'))
36
+ const upstreamVersion = await readVersion(resolve(upstreamRuntime, 'package.json'))
37
+ const upstreamCommit = await readSubmoduleCommit()
38
+
39
+ await rm(outdir, { recursive: true, force: true })
40
+ await mkdir(outdir, { recursive: true })
41
+
42
+ await build({
43
+ entryPoints: { cli: resolve(root, 'src/cli.ts') },
44
+ bundle: true,
45
+ platform: 'node',
46
+ format: 'esm',
47
+ target: 'node20',
48
+ packages: 'bundle',
49
+ define: {
50
+ __CLOUD_RUNTIME_VERSION__: JSON.stringify(cloudVersion),
51
+ __UPSTREAM_RUNTIME_VERSION__: JSON.stringify(upstreamVersion),
52
+ __UPSTREAM_RUNTIME_COMMIT__: JSON.stringify(upstreamCommit),
53
+ },
54
+ // grpc-js and ws are CommonJS. The bundled ESM artifact needs a require shim
55
+ // to load them — the same bridge the upstream build installs. esbuild emits
56
+ // the entry's shebang above this banner, so the bin stays executable.
57
+ banner: {
58
+ js: [
59
+ `// @memohai/cloud-runtime ${cloudVersion} — bundles @memohai/runtime ${upstreamVersion} (memoh ${upstreamCommit})`,
60
+ "import { createRequire as __memohCreateRequire } from 'node:module'",
61
+ 'const require = __memohCreateRequire(import.meta.url)',
62
+ ].join('\n'),
63
+ },
64
+ outdir,
65
+ outExtension: { '.js': '.mjs' },
66
+ })
67
+
68
+ await chmod(resolve(outdir, 'cli.mjs'), 0o755)
69
+
70
+ // service.ts resolves the descriptor next to the built artifact first, so the
71
+ // canonical proto has to travel with the bundle.
72
+ await copyFile(protoSource, resolve(outdir, 'bridge.proto'))
73
+
74
+ await verifyBundleLoads()
75
+ await verifyVersionStamp()
76
+ await verifyHelpBranding()
77
+
78
+ /**
79
+ * Runs the bundle far enough to prove its whole module graph evaluates under
80
+ * ESM — the CommonJS grpc-js/ws bridge above is the fragile part, and it only
81
+ * fails at load time. The upstream CLI validates arguments *after* that graph
82
+ * is live, so its own error is the success signal here.
83
+ */
84
+ async function verifyBundleLoads() {
85
+ const { code, stderr, stdout } = await run(process.execPath, [resolve(outdir, 'cli.mjs')], {
86
+ // Scrub the developer's own credentials: with a key present the CLI would
87
+ // skip validation and try to connect, hanging the build.
88
+ ...process.env,
89
+ MEMOH_RUNTIME_SERVER: '',
90
+ MEMOH_RUNTIME_KEY: '',
91
+ MEMOH_RUNTIME_TEAM_ID: '',
92
+ })
93
+ if (!stderr.includes('--server and --key are required')) {
94
+ throw new Error(`bundle smoke test failed (exit ${code}):\n${stderr || stdout}`)
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Guards the `define` wiring: an un-substituted stamp silently degrades to
100
+ * "unknown" at runtime rather than failing the build, which would leave
101
+ * published artifacts unable to identify themselves.
102
+ */
103
+ async function verifyVersionStamp() {
104
+ const { code, stdout, stderr } = await run(
105
+ process.execPath,
106
+ [resolve(outdir, 'cli.mjs'), '--version'],
107
+ process.env,
108
+ )
109
+ if (!stdout.includes(upstreamVersion) || !stdout.includes(upstreamCommit)) {
110
+ throw new Error(`version stamp missing from the bundle (exit ${code}):\n${stdout || stderr}`)
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Guards the import order in src/cli.ts — the one thing this package's design
116
+ * hinges on. If the upstream entry ever evaluates first, --help prints
117
+ * upstream's `memoh-runtime` usage (or worse, starts a session) before
118
+ * bootstrap can intercept. The branding is the observable proof that
119
+ * bootstrap still runs first.
120
+ */
121
+ async function verifyHelpBranding() {
122
+ const { stderr, stdout } = await run(
123
+ process.execPath,
124
+ [resolve(outdir, 'cli.mjs'), '--help'],
125
+ process.env,
126
+ )
127
+ if (!stderr.includes('memoh-cloud-runtime') || stderr.includes('Usage: memoh-runtime ')) {
128
+ throw new Error(`--help is not Cloud-branded — bootstrap did not run first:\n${stderr || stdout}`)
129
+ }
130
+ }
131
+
132
+ async function readVersion(packageJsonPath) {
133
+ const { version } = JSON.parse(await readFile(packageJsonPath, 'utf8'))
134
+ if (!version) {
135
+ throw new Error(`no version field in ${packageJsonPath}`)
136
+ }
137
+ return version
138
+ }
139
+
140
+ /**
141
+ * Identifies the exact submodule tree that was bundled. Falls back to "unknown"
142
+ * rather than failing: builds from a source tarball have no git metadata, and
143
+ * that is not a reason to block a release.
144
+ */
145
+ async function readSubmoduleCommit() {
146
+ // A missing git *binary* rejects the spawn promise outright — same
147
+ // "no git metadata" situation as a failing rev-parse, same fallback.
148
+ try {
149
+ const head = await run('git', ['-C', submodule, 'rev-parse', '--short=8', 'HEAD'], process.env)
150
+ if (head.code !== 0) {
151
+ return 'unknown'
152
+ }
153
+ const commit = head.stdout.trim()
154
+ // A dirty submodule means the bundle contains code that exists in no
155
+ // commit; saying so is the whole point of the stamp.
156
+ const status = await run('git', ['-C', submodule, 'status', '--porcelain'], process.env)
157
+ return status.code === 0 && status.stdout.trim() ? `${commit}-dirty` : commit
158
+ } catch {
159
+ return 'unknown'
160
+ }
161
+ }
162
+
163
+ function run(command, args, env) {
164
+ return new Promise((resolvePromise, rejectPromise) => {
165
+ const child = spawn(command, args, { env, stdio: ['ignore', 'pipe', 'pipe'] })
166
+ const timer = setTimeout(() => {
167
+ child.kill('SIGKILL')
168
+ rejectPromise(new Error('bundle smoke test timed out after 30s'))
169
+ }, 30_000)
170
+ let stdout = ''
171
+ let stderr = ''
172
+ child.stdout.on('data', chunk => (stdout += chunk))
173
+ child.stderr.on('data', chunk => (stderr += chunk))
174
+ child.on('error', error => {
175
+ clearTimeout(timer)
176
+ rejectPromise(error)
177
+ })
178
+ child.on('close', code => {
179
+ clearTimeout(timer)
180
+ resolvePromise({ code, stdout, stderr })
181
+ })
182
+ })
183
+ }
184
+
185
+ async function requirePath(path, message) {
186
+ try {
187
+ await access(path)
188
+ } catch {
189
+ throw new Error(message)
190
+ }
191
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Side-effect module: adjusts process state before the upstream CLI runs.
3
+ *
4
+ * cli.ts imports this ahead of the upstream entry point, and ES modules
5
+ * evaluate their imports depth-first in source order, so this body is
6
+ * guaranteed to finish first — which matters because the upstream module
7
+ * starts a session as soon as *its* body evaluates. That ordering is a
8
+ * language guarantee, not a bundler behaviour, so it survives esbuild.
9
+ */
10
+ import { versionReport } from './build-info'
11
+ import { applyCloudDefaults, cloudUsage } from './cloud'
12
+
13
+ const args = process.argv.slice(2)
14
+
15
+ // Both flags are handled here rather than upstream: the usage string there
16
+ // names `memoh-runtime` and cannot know the Cloud default endpoint, and
17
+ // upstream has no --version at all.
18
+ if (args.includes('--help') || args.includes('-h')) {
19
+ // stderr and exit 0, matching upstream's usage() contract.
20
+ console.error(cloudUsage())
21
+ process.exit(0)
22
+ }
23
+
24
+ if (args.includes('--version') || args.includes('-v')) {
25
+ console.log(versionReport())
26
+ process.exit(0)
27
+ }
28
+
29
+ applyCloudDefaults(args, process.env)
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Build stamps injected by scripts/build.mjs through esbuild `define`.
3
+ *
4
+ * The artifact is the only place that can answer which upstream runtime it
5
+ * contains. This package bundles its implementation from the packages/memoh
6
+ * submodule at build time, so the upstream version is pinned by a gitlink
7
+ * rather than by a semver range in package.json — nothing in the published
8
+ * tarball would otherwise record it. Stamping it here is what lets a build in
9
+ * the wild identify itself during an incident.
10
+ *
11
+ * Drop this module when the package moves to a plain npm dependency on
12
+ * @memohai/runtime: at that point the lockfile records the version instead.
13
+ */
14
+
15
+ const unknownStamp = 'unknown'
16
+
17
+ declare const __CLOUD_RUNTIME_VERSION__: string
18
+ declare const __UPSTREAM_RUNTIME_VERSION__: string
19
+ declare const __UPSTREAM_RUNTIME_COMMIT__: string
20
+
21
+ // The `typeof` guards keep this module loadable outside the bundle, where the
22
+ // identifiers are never declared — tests and `tsc` import it directly. Reading
23
+ // an undeclared identifier throws; `typeof` on one does not. Inside the bundle
24
+ // esbuild substitutes literals and these fold away.
25
+ export const cloudRuntimeVersion
26
+ = typeof __CLOUD_RUNTIME_VERSION__ === 'string' ? __CLOUD_RUNTIME_VERSION__ : unknownStamp
27
+
28
+ export const upstreamRuntimeVersion
29
+ = typeof __UPSTREAM_RUNTIME_VERSION__ === 'string' ? __UPSTREAM_RUNTIME_VERSION__ : unknownStamp
30
+
31
+ /** Short commit of the packages/memoh submodule, `-dirty` when built from edits. */
32
+ export const upstreamRuntimeCommit
33
+ = typeof __UPSTREAM_RUNTIME_COMMIT__ === 'string' ? __UPSTREAM_RUNTIME_COMMIT__ : unknownStamp
34
+
35
+ export function versionReport(): string {
36
+ return [
37
+ `@memohai/cloud-runtime ${cloudRuntimeVersion}`,
38
+ `bundles @memohai/runtime ${upstreamRuntimeVersion} (memoh ${upstreamRuntimeCommit})`,
39
+ ].join('\n')
40
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Import order is load-bearing. ./bootstrap seeds the Cloud endpoint and may
4
+ // exit for --help; the upstream module below connects the moment it evaluates,
5
+ // so it must come second. See src/bootstrap.ts.
6
+ import './bootstrap'
7
+ import '../../memoh/packages/runtime/src/cli'
package/src/cloud.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Everything Memoh Cloud adds on top of the upstream runtime CLI.
3
+ *
4
+ * The rest of this package *is* @memohai/runtime, bundled straight from the
5
+ * packages/memoh submodule at build time (see scripts/build.mjs). Keeping the
6
+ * Cloud behaviour confined to this file is what makes the divergence from open
7
+ * source auditable: if this file is empty, the two CLIs are identical.
8
+ */
9
+
10
+ /**
11
+ * Where the CLI dials when the operator does not say otherwise.
12
+ *
13
+ * `app.memoh.net` is the Cloud public ingress (deploy/AGENTS.md) and the BFF
14
+ * mounts the shared Memoh data plane under `/api/memoh`. The CLI appends
15
+ * `/runtimes/connect` itself, so this value stays an origin plus prefix — see
16
+ * packages/bff/internal/handler/runtime_connect.go.
17
+ */
18
+ export const cloudDefaultServerUrl = 'https://app.memoh.net/api/memoh'
19
+
20
+ /** The environment variable the upstream CLI reads as its `--server` fallback. */
21
+ export const serverUrlEnvVar = 'MEMOH_RUNTIME_SERVER'
22
+
23
+ /**
24
+ * Seeds the Cloud endpoint without taking precedence away from the operator.
25
+ *
26
+ * Resolution order stays `--server` > `MEMOH_RUNTIME_SERVER` > Cloud default.
27
+ * The upstream CLI already prefers the flag over the environment variable, so
28
+ * seeding the variable is enough to add a default — and it must never overwrite
29
+ * a value the caller set deliberately (staging, a tunnel, a self-hosted
30
+ * install), which is why both escape hatches are checked first.
31
+ */
32
+ export function applyCloudDefaults(args: readonly string[], env: NodeJS.ProcessEnv): void {
33
+ // The prefix match also catches `--server=url`, a spelling upstream does NOT
34
+ // parse. Seeding the default there would silently redirect a mistyped-but-
35
+ // explicit endpoint to Cloud production; deferring lets upstream's own
36
+ // usage error surface so the caller fixes the spelling instead.
37
+ if (args.some(arg => arg === '--server' || arg.startsWith('--server='))) {
38
+ return
39
+ }
40
+ if (env[serverUrlEnvVar]?.trim()) {
41
+ return
42
+ }
43
+ env[serverUrlEnvVar] = cloudDefaultServerUrl
44
+ }
45
+
46
+ /**
47
+ * Cloud-branded help text. The upstream string names `memoh-runtime` and omits
48
+ * the default endpoint, so this package prints its own and never reaches it.
49
+ */
50
+ export function cloudUsage(): string {
51
+ return [
52
+ 'Usage: memoh-cloud-runtime --key <key> [--team-id <uuid>] [--server <url>] [--insecure-localhost]',
53
+ '',
54
+ 'Connects this machine to Memoh Cloud as a remote runtime.',
55
+ '',
56
+ ` --server defaults to ${cloudDefaultServerUrl}`,
57
+ ' (or $MEMOH_RUNTIME_SERVER when exported)',
58
+ ' --key team-scoped runtime key, mrk_...',
59
+ ' --team-id tenant UUID issued alongside the key',
60
+ ' --version this build, and the upstream runtime bundled into it',
61
+ ].join('\n')
62
+ }