openpalm 0.10.2 → 0.11.0-beta.2

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.
Files changed (47) hide show
  1. package/README.md +11 -19
  2. package/package.json +4 -2
  3. package/src/commands/addon.ts +5 -4
  4. package/src/commands/automations.ts +63 -0
  5. package/src/commands/install.ts +98 -280
  6. package/src/commands/logs.ts +1 -1
  7. package/src/commands/restart.ts +5 -4
  8. package/src/commands/rollback.ts +4 -3
  9. package/src/commands/scan.ts +66 -32
  10. package/src/commands/service.ts +5 -4
  11. package/src/commands/start.ts +5 -4
  12. package/src/commands/status.ts +1 -1
  13. package/src/commands/stop.ts +2 -4
  14. package/src/commands/uninstall.ts +3 -5
  15. package/src/commands/update.ts +19 -2
  16. package/src/commands/validate.ts +16 -34
  17. package/src/install-flow.test.ts +153 -154
  18. package/src/lib/admin-skills/index.test.ts +70 -0
  19. package/src/lib/admin-skills/index.ts +113 -0
  20. package/src/lib/browser.ts +20 -0
  21. package/src/lib/cli-compose.ts +2 -20
  22. package/src/lib/cli-state.ts +1 -1
  23. package/src/lib/docker.ts +8 -214
  24. package/src/lib/env.ts +12 -83
  25. package/src/lib/io.ts +130 -0
  26. package/src/lib/opencode-subprocess.ts +14 -6
  27. package/src/lib/paths.ts +2 -2
  28. package/src/lib/ui-server.ts +150 -0
  29. package/src/main.test.ts +76 -173
  30. package/src/main.ts +131 -7
  31. package/e2e/start-wizard-server.ts +0 -59
  32. package/src/commands/admin.ts +0 -43
  33. package/src/commands/install-services.test.ts +0 -13
  34. package/src/commands/install-services.ts +0 -9
  35. package/src/commands/upgrade.ts +0 -12
  36. package/src/lib/embedded-assets.ts +0 -115
  37. package/src/lib/varlock.ts +0 -126
  38. package/src/setup-wizard/index.html +0 -321
  39. package/src/setup-wizard/server-errors.test.ts +0 -418
  40. package/src/setup-wizard/server-integration.test.ts +0 -511
  41. package/src/setup-wizard/server.test.ts +0 -508
  42. package/src/setup-wizard/server.ts +0 -342
  43. package/src/setup-wizard/wizard-renderers.js +0 -1294
  44. package/src/setup-wizard/wizard-state.js +0 -346
  45. package/src/setup-wizard/wizard-validators.js +0 -81
  46. package/src/setup-wizard/wizard.css +0 -1611
  47. package/src/setup-wizard/wizard.js +0 -613
package/src/main.ts CHANGED
@@ -1,12 +1,90 @@
1
1
  #!/usr/bin/env bun
2
2
  import { defineCommand, runCommand, runMain } from 'citty';
3
+ import { join } from 'node:path';
3
4
  import cliPkg from '../package.json' with { type: 'json' };
5
+ import { resolveConfigDir } from '@openpalm/lib';
4
6
 
5
7
  // Re-export public API used by tests and external consumers
6
8
  export { detectHostInfo } from './lib/host-info.ts';
7
9
  export type { HostInfo } from './lib/host-info.ts';
8
- export { upsertEnvValue, resolveRequestedImageTag, reconcileStackEnvImageTag } from './lib/env.ts';
9
- export { bootstrapInstall } from './commands/install.ts';
10
+
11
+ const SUBCOMMAND_NAMES = new Set([
12
+ 'install', 'uninstall', 'update', 'self-update', 'addon',
13
+ 'start', 'stop', 'restart', 'logs', 'status', 'service',
14
+ 'validate', 'scan', 'rollback', 'automations',
15
+ '--help', '-h', 'help',
16
+ ]);
17
+
18
+ interface BareRunOpts {
19
+ port?: number;
20
+ open?: boolean;
21
+ }
22
+
23
+ /**
24
+ * Probe the assistant container's healthcheck to decide whether the stack
25
+ * is already up. We hit the assistant's published host port (default 3800,
26
+ * overridable via OP_ASSISTANT_PORT) rather than introspect Docker so this
27
+ * works without docker socket access and respects whatever overrides are
28
+ * active.
29
+ */
30
+ async function isAssistantHealthy(): Promise<boolean> {
31
+ const port = process.env.OP_ASSISTANT_PORT ?? '3800';
32
+ try {
33
+ const res = await fetch(`http://127.0.0.1:${port}/health`, {
34
+ signal: AbortSignal.timeout(1500),
35
+ });
36
+ return res.ok;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Smart default: `openpalm` (no subcommand) detects state and does the
44
+ * right thing automatically.
45
+ *
46
+ * - Not installed → runs the install flow (seeds OP_HOME, spawns wizard)
47
+ * - Installed, stack down → starts the stack
48
+ * - Installed, stack up → starts the UI host server (foreground)
49
+ *
50
+ * The UI server runs in the foreground until SIGINT/SIGTERM. This is
51
+ * the canonical way to "run OpenPalm" — no separate `ui`/`admin`
52
+ * subcommand.
53
+ */
54
+ async function autoRun(opts: BareRunOpts = {}): Promise<void> {
55
+ const stackEnv = join(resolveConfigDir(), 'stack', 'stack.env');
56
+ const isInstalled = await Bun.file(stackEnv).exists();
57
+
58
+ if (!isInstalled) {
59
+ const { bootstrapInstall, resolveDefaultInstallRef } = await import('./commands/install.ts') as any;
60
+ const version: string = typeof resolveDefaultInstallRef === 'function'
61
+ ? await resolveDefaultInstallRef()
62
+ : (cliPkg.version ? `v${cliPkg.version}` : 'main');
63
+ await bootstrapInstall({
64
+ force: false,
65
+ version,
66
+ noStart: false,
67
+ noOpen: opts.open === false,
68
+ });
69
+ return;
70
+ }
71
+
72
+ // Ensure the stack is up. Skip when the assistant is already healthy —
73
+ // calling `docker compose up -d` would otherwise recreate containers
74
+ // (when compose config differs, e.g. dev overlays add port bindings)
75
+ // and tear down test/dev port mappings.
76
+ const stackAlreadyUp = await isAssistantHealthy();
77
+ if (!stackAlreadyUp) {
78
+ const { runStartAction } = await import('./commands/start.ts');
79
+ await runStartAction([]).catch((err) => {
80
+ console.warn(`Warning: failed to ensure stack is running: ${err instanceof Error ? err.message : String(err)}`);
81
+ });
82
+ }
83
+
84
+ // Start the UI host server in the foreground (blocks until SIGINT/SIGTERM).
85
+ const { startUIServer } = await import('./lib/ui-server.ts');
86
+ await startUIServer({ port: opts.port, open: opts.open });
87
+ }
10
88
 
11
89
  export const mainCommand = defineCommand({
12
90
  meta: {
@@ -14,14 +92,23 @@ export const mainCommand = defineCommand({
14
92
  version: cliPkg.version,
15
93
  description: 'OpenPalm CLI — install and manage a self-hosted OpenPalm stack',
16
94
  },
95
+ args: {
96
+ port: {
97
+ type: 'string',
98
+ description: 'UI server port (default: 3880 or OP_HOST_UI_PORT)',
99
+ },
100
+ open: {
101
+ type: 'boolean',
102
+ description: 'Open browser after start (use --no-open to skip)',
103
+ default: true,
104
+ },
105
+ },
17
106
  subCommands: {
18
107
  install: () => import('./commands/install.ts').then((m) => m.default),
19
108
  uninstall: () => import('./commands/uninstall.ts').then((m) => m.default),
20
109
  update: () => import('./commands/update.ts').then((m) => m.default),
21
- upgrade: () => import('./commands/upgrade.ts').then((m) => m.default),
22
110
  'self-update': () => import('./commands/self-update.ts').then((m) => m.default),
23
111
  addon: () => import('./commands/addon.ts').then((m) => m.default),
24
- admin: () => import('./commands/admin.ts').then((m) => m.default),
25
112
  start: () => import('./commands/start.ts').then((m) => m.default),
26
113
  stop: () => import('./commands/stop.ts').then((m) => m.default),
27
114
  restart: () => import('./commands/restart.ts').then((m) => m.default),
@@ -31,18 +118,55 @@ export const mainCommand = defineCommand({
31
118
  validate: () => import('./commands/validate.ts').then((m) => m.default),
32
119
  scan: () => import('./commands/scan.ts').then((m) => m.default),
33
120
  rollback: () => import('./commands/rollback.ts').then((m) => m.default),
121
+ automations: () => import('./commands/automations.ts').then((m) => m.default),
34
122
  },
35
123
  });
36
124
 
125
+ /** Parse `--port`/`--no-open` from a bare-command argv. */
126
+ function parseBareArgs(argv: string[]): BareRunOpts {
127
+ const opts: BareRunOpts = {};
128
+ for (let i = 0; i < argv.length; i++) {
129
+ if (argv[i] === '--port' && argv[i + 1]) {
130
+ opts.port = Number(argv[++i]);
131
+ } else if (argv[i]?.startsWith('--port=')) {
132
+ opts.port = Number(argv[i]!.split('=')[1]);
133
+ } else if (argv[i] === '--no-open') {
134
+ opts.open = false;
135
+ }
136
+ }
137
+ return opts;
138
+ }
139
+
37
140
  /**
38
141
  * Programmatic entry point for tests and embedding.
39
- * Uses runCommand directly (not runMain) to avoid the process.exit(1) wrapper
40
- * and process.argv manipulation.
142
+ *
143
+ * No-subcommand behaviour: autoRun() detects state and does the right thing.
144
+ * Subcommand: route through citty.
41
145
  */
42
146
  export async function main(argv = process.argv.slice(2)): Promise<void> {
147
+ if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-v')) {
148
+ console.log(cliPkg.version);
149
+ return;
150
+ }
151
+
152
+ const hasSubcommand = argv.length > 0 && SUBCOMMAND_NAMES.has(argv[0]!);
153
+ if (!hasSubcommand) {
154
+ await autoRun(parseBareArgs(argv));
155
+ return;
156
+ }
157
+
43
158
  await runCommand(mainCommand, { rawArgs: argv });
44
159
  }
45
160
 
46
161
  if (import.meta.main) {
47
- await runMain(mainCommand);
162
+ const argv = process.argv.slice(2);
163
+ if (argv.length === 0 || !SUBCOMMAND_NAMES.has(argv[0]!)) {
164
+ if (argv[0] === '--version' || argv[0] === '-v') {
165
+ console.log(cliPkg.version);
166
+ } else {
167
+ await autoRun(parseBareArgs(argv));
168
+ }
169
+ } else {
170
+ await runMain(mainCommand);
171
+ }
48
172
  }
@@ -1,59 +0,0 @@
1
- /**
2
- * Bun-only launcher for the CLI setup wizard server.
3
- *
4
- * Called from Playwright tests as a child process:
5
- * bun run packages/cli/e2e/start-wizard-server.ts <port>
6
- *
7
- * Starts the wizard server on the given port with a temp config directory
8
- * so tests do not affect real dev state. Prints "WIZARD_READY:<port>" to
9
- * stdout when listening, which the Playwright test waits for.
10
- */
11
- import { createSetupServer } from "../src/setup-wizard/server.ts";
12
- import { mkdirSync, writeFileSync } from "node:fs";
13
-
14
- const port = parseInt(Bun.argv[2] || "18100", 10);
15
- const tmpBase = `/tmp/openpalm-wizard-test-${port}`;
16
-
17
- // Create minimal directory structure so the server can start.
18
- // API endpoints that need real files are mocked at the browser level
19
- // by Playwright's page.route(), so these dirs just prevent crashes.
20
- mkdirSync(`${tmpBase}/config`, { recursive: true });
21
- mkdirSync(`${tmpBase}/config/automations`, { recursive: true });
22
- mkdirSync(`${tmpBase}/data`, { recursive: true });
23
- mkdirSync(`${tmpBase}/data/assistant`, { recursive: true });
24
- mkdirSync(`${tmpBase}/registry/automations`, { recursive: true });
25
- mkdirSync(`${tmpBase}/stack`, { recursive: true });
26
- mkdirSync(`${tmpBase}/vault/stack`, { recursive: true });
27
- mkdirSync(`${tmpBase}/vault/user`, { recursive: true });
28
-
29
- writeFileSync(`${tmpBase}/vault/stack/stack.env`, "OP_SETUP_COMPLETE=false\n");
30
- writeFileSync(`${tmpBase}/vault/user/user.env`, "# test\n");
31
-
32
- // Seed minimal asset files so performSetup() can read them if invoked
33
- writeFileSync(`${tmpBase}/stack/core.compose.yml`, "services:\n admin:\n image: admin:latest\n");
34
- writeFileSync(`${tmpBase}/data/assistant/opencode.jsonc`, '{"$schema":"https://opencode.ai/config.json"}\n');
35
- writeFileSync(`${tmpBase}/data/assistant/AGENTS.md`, "# Agents\n");
36
- writeFileSync(`${tmpBase}/vault/user/user.env.schema`, "OP_ADMIN_TOKEN=string\n");
37
- writeFileSync(`${tmpBase}/vault/stack/stack.env.schema`, "OP_IMAGE_TAG=string\n");
38
- writeFileSync(`${tmpBase}/registry/automations/cleanup-logs.yml`, "name: cleanup-logs\nschedule: daily\n");
39
- writeFileSync(`${tmpBase}/registry/automations/cleanup-data.yml`, "name: cleanup-data\nschedule: weekly\n");
40
- writeFileSync(`${tmpBase}/registry/automations/validate-config.yml`, "name: validate-config\nschedule: hourly\n");
41
-
42
- // Override state/config home so the server doesn't touch real dirs.
43
- process.env.OP_HOME = tmpBase;
44
-
45
- const { server } = createSetupServer(port, {
46
- configDir: `${tmpBase}/config`,
47
- });
48
-
49
- console.log(`WIZARD_READY:${port}`);
50
-
51
- // Keep alive until killed
52
- process.on("SIGTERM", () => {
53
- server.stop();
54
- process.exit(0);
55
- });
56
- process.on("SIGINT", () => {
57
- server.stop();
58
- process.exit(0);
59
- });
@@ -1,43 +0,0 @@
1
- import { defineCommand } from 'citty';
2
- import { listEnabledAddonIds } from '@openpalm/lib';
3
- import { ensureValidState } from '../lib/cli-state.ts';
4
- import { runAddonDisableAction, runAddonEnableAction } from './addon.ts';
5
-
6
- async function runAdminStatusAction(): Promise<void> {
7
- const state = ensureValidState();
8
- const enabled = listEnabledAddonIds(state.homeDir).includes('admin');
9
- console.log(enabled ? 'Admin addon is enabled.' : 'Admin addon is disabled.');
10
- }
11
-
12
- const enableCmd = defineCommand({
13
- meta: { name: 'enable', description: 'Enable the admin addon' },
14
- async run() {
15
- await runAddonEnableAction('admin');
16
- },
17
- });
18
-
19
- const disableCmd = defineCommand({
20
- meta: { name: 'disable', description: 'Disable the admin addon' },
21
- async run() {
22
- await runAddonDisableAction('admin');
23
- },
24
- });
25
-
26
- const statusCmd = defineCommand({
27
- meta: { name: 'status', description: 'Show whether the admin addon is enabled' },
28
- async run() {
29
- await runAdminStatusAction();
30
- },
31
- });
32
-
33
- export default defineCommand({
34
- meta: {
35
- name: 'admin',
36
- description: 'Enable, disable, or inspect the admin addon',
37
- },
38
- subCommands: {
39
- enable: enableCmd,
40
- disable: disableCmd,
41
- status: statusCmd,
42
- },
43
- });
@@ -1,13 +0,0 @@
1
- import { describe, expect, it } from 'bun:test';
2
- import { buildDeployStatusEntries } from './install-services.ts';
3
-
4
- describe('install service helpers', () => {
5
- it('builds deploy status entries for the install service list', () => {
6
- const services = ['memory', 'assistant'];
7
-
8
- expect(buildDeployStatusEntries(services, 'pending', 'Waiting...')).toEqual([
9
- { service: 'memory', status: 'pending', label: 'Waiting...' },
10
- { service: 'assistant', status: 'pending', label: 'Waiting...' },
11
- ]);
12
- });
13
- });
@@ -1,9 +0,0 @@
1
- export type DeployStatusState = 'pending' | 'pulling' | 'error';
2
-
3
- export function buildDeployStatusEntries(
4
- services: string[],
5
- status: DeployStatusState,
6
- label: string,
7
- ): Array<{ service: string; status: DeployStatusState; label: string }> {
8
- return services.map(service => ({ service, status, label }));
9
- }
@@ -1,12 +0,0 @@
1
- import { defineCommand } from 'citty';
2
- import { runUpgradeAction } from './update.ts';
3
-
4
- export default defineCommand({
5
- meta: {
6
- name: 'upgrade',
7
- description: 'Refresh stack assets, pull latest images, and recreate containers',
8
- },
9
- async run() {
10
- await runUpgradeAction();
11
- },
12
- });
@@ -1,115 +0,0 @@
1
- /**
2
- * Core assets embedded at build time via Bun text imports.
3
- *
4
- * Source of truth is .openpalm/ at the repo root. Bun inlines the file
5
- * contents at compile time so they're available in compiled binaries
6
- * without downloading from GitHub.
7
- */
8
-
9
- // @ts-ignore — Bun text import
10
- import coreCompose from "../../../../.openpalm/stack/core.compose.yml" with { type: "text" };
11
- // @ts-ignore — Bun text import
12
- import userEnvSchema from "../../../../.openpalm/vault/user/user.env.schema" with { type: "text" };
13
- // @ts-ignore — Bun text import
14
- import stackEnvSchema from "../../../../.openpalm/vault/stack/stack.env.schema" with { type: "text" };
15
-
16
- // Addon compose files
17
- // @ts-ignore — Bun text import
18
- import adminCompose from "../../../../.openpalm/registry/addons/admin/compose.yml" with { type: "text" };
19
- // @ts-ignore — Bun text import
20
- import adminSchema from "../../../../.openpalm/registry/addons/admin/.env.schema" with { type: "text" };
21
- // @ts-ignore — Bun text import
22
- import chatCompose from "../../../../.openpalm/registry/addons/chat/compose.yml" with { type: "text" };
23
- // @ts-ignore — Bun text import
24
- import chatSchema from "../../../../.openpalm/registry/addons/chat/.env.schema" with { type: "text" };
25
- // @ts-ignore — Bun text import
26
- import apiCompose from "../../../../.openpalm/registry/addons/api/compose.yml" with { type: "text" };
27
- // @ts-ignore — Bun text import
28
- import apiSchema from "../../../../.openpalm/registry/addons/api/.env.schema" with { type: "text" };
29
- // @ts-ignore — Bun text import
30
- import discordCompose from "../../../../.openpalm/registry/addons/discord/compose.yml" with { type: "text" };
31
- // @ts-ignore — Bun text import
32
- import discordSchema from "../../../../.openpalm/registry/addons/discord/.env.schema" with { type: "text" };
33
- // @ts-ignore — Bun text import
34
- import slackCompose from "../../../../.openpalm/registry/addons/slack/compose.yml" with { type: "text" };
35
- // @ts-ignore — Bun text import
36
- import slackSchema from "../../../../.openpalm/registry/addons/slack/.env.schema" with { type: "text" };
37
- // @ts-ignore — Bun text import
38
- import ollamaCompose from "../../../../.openpalm/registry/addons/ollama/compose.yml" with { type: "text" };
39
- // @ts-ignore — Bun text import
40
- import ollamaSchema from "../../../../.openpalm/registry/addons/ollama/.env.schema" with { type: "text" };
41
- // @ts-ignore — Bun text import
42
- import voiceCompose from "../../../../.openpalm/registry/addons/voice/compose.yml" with { type: "text" };
43
- // @ts-ignore — Bun text import
44
- import voiceSchema from "../../../../.openpalm/registry/addons/voice/.env.schema" with { type: "text" };
45
- // @ts-ignore — Bun text import
46
- import openvikingCompose from "../../../../.openpalm/registry/addons/openviking/compose.yml" with { type: "text" };
47
- // @ts-ignore — Bun text import
48
- import openvikingSchema from "../../../../.openpalm/registry/addons/openviking/.env.schema" with { type: "text" };
49
- // @ts-ignore — Bun text import
50
- import openvikingConfig from "../../../../.openpalm/registry/addons/openviking/config/ov.conf" with { type: "text" };
51
- // @ts-ignore — Bun text import
52
- import memoryConfigTemplate from "../../../../.openpalm/config/memory/memory.conf.json" with { type: "text" };
53
- // @ts-ignore — Bun text import
54
- import cleanupLogsAutomation from "../../../../.openpalm/registry/automations/cleanup-logs.yml" with { type: "text" };
55
- // @ts-ignore — Bun text import
56
- import cleanupDataAutomation from "../../../../.openpalm/registry/automations/cleanup-data.yml" with { type: "text" };
57
- // @ts-ignore — Bun text import
58
- import validateConfigAutomation from "../../../../.openpalm/registry/automations/validate-config.yml" with { type: "text" };
59
- // @ts-ignore — Bun text import
60
- import healthCheckAutomation from "../../../../.openpalm/registry/automations/health-check.yml" with { type: "text" };
61
- // @ts-ignore — Bun text import
62
- import promptAssistantAutomation from "../../../../.openpalm/registry/automations/prompt-assistant.yml" with { type: "text" };
63
- // @ts-ignore — Bun text import
64
- import updateContainersAutomation from "../../../../.openpalm/registry/automations/update-containers.yml" with { type: "text" };
65
- // @ts-ignore — Bun text import
66
- import assistantDailyBriefingAutomation from "../../../../.openpalm/registry/automations/assistant-daily-briefing.yml" with { type: "text" };
67
-
68
- export const EMBEDDED_ASSETS: Record<string, string> = {
69
- "stack/core.compose.yml": coreCompose,
70
- "registry/addons/admin/compose.yml": adminCompose,
71
- "registry/addons/admin/.env.schema": adminSchema,
72
- "registry/addons/chat/compose.yml": chatCompose,
73
- "registry/addons/chat/.env.schema": chatSchema,
74
- "registry/addons/api/compose.yml": apiCompose,
75
- "registry/addons/api/.env.schema": apiSchema,
76
- "registry/addons/discord/compose.yml": discordCompose,
77
- "registry/addons/discord/.env.schema": discordSchema,
78
- "registry/addons/slack/compose.yml": slackCompose,
79
- "registry/addons/slack/.env.schema": slackSchema,
80
- "registry/addons/ollama/compose.yml": ollamaCompose,
81
- "registry/addons/ollama/.env.schema": ollamaSchema,
82
- "registry/addons/voice/compose.yml": voiceCompose,
83
- "registry/addons/voice/.env.schema": voiceSchema,
84
- "registry/addons/openviking/compose.yml": openvikingCompose,
85
- "registry/addons/openviking/.env.schema": openvikingSchema,
86
- "registry/addons/openviking/config/ov.conf": openvikingConfig,
87
- "config/memory/memory.conf.json": memoryConfigTemplate,
88
- "registry/automations/cleanup-logs.yml": cleanupLogsAutomation,
89
- "registry/automations/cleanup-data.yml": cleanupDataAutomation,
90
- "registry/automations/validate-config.yml": validateConfigAutomation,
91
- "registry/automations/health-check.yml": healthCheckAutomation,
92
- "registry/automations/prompt-assistant.yml": promptAssistantAutomation,
93
- "registry/automations/update-containers.yml": updateContainersAutomation,
94
- "registry/automations/assistant-daily-briefing.yml": assistantDailyBriefingAutomation,
95
- "vault/user/user.env.schema": userEnvSchema,
96
- "vault/stack/stack.env.schema": stackEnvSchema,
97
- };
98
-
99
- /**
100
- * Seed critical assets from embedded content (compiled into the Bun binary).
101
- * Only writes files that don't already exist — never overwrites user edits.
102
- *
103
- * CLI-only — the admin reads assets from the filesystem at runtime.
104
- */
105
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
106
- import { dirname, join } from "node:path";
107
-
108
- export function seedEmbeddedAssets(homeDir: string): void {
109
- for (const [relPath, content] of Object.entries(EMBEDDED_ASSETS)) {
110
- const targetPath = join(homeDir, relPath);
111
- if (existsSync(targetPath)) continue;
112
- mkdirSync(dirname(targetPath), { recursive: true });
113
- writeFileSync(targetPath, content);
114
- }
115
- }
@@ -1,126 +0,0 @@
1
- import { copyFile, mkdir, mkdtemp, unlink } from 'node:fs/promises';
2
- import { tmpdir } from 'node:os';
3
- import { join } from 'node:path';
4
-
5
- const VARLOCK_VERSION = '0.4.0';
6
-
7
- const VARLOCK_CHECKSUMS: Record<string, string> = {
8
- 'varlock-linux-x64.tar.gz': '820295b271cece2679b2b9701b5285ce39354fc2f35797365fa36c70125f51ab',
9
- 'varlock-linux-arm64.tar.gz': 'e830baaa901b6389ecf281bdd2449bfaf7586e91fd3a7a038ec06f78e6fa92f8',
10
- 'varlock-macos-x64.tar.gz': 'e6abf0d97da8ff7c98b0e9044a8b71f48fbf74a0d7bfc2543a81575a07b7a03b',
11
- 'varlock-macos-arm64.tar.gz': '228e4c2666b9fa50a83a8713a848e7a0f0044d7fd7c9d441d43e6ebccad2f4a3',
12
- };
13
-
14
- function varlockArtifactName(): string {
15
- const platformMap: Record<string, string> = {
16
- linux: 'linux',
17
- darwin: 'macos',
18
- };
19
- const archMap: Record<string, string> = {
20
- x64: 'x64',
21
- arm64: 'arm64',
22
- };
23
-
24
- const os = platformMap[process.platform];
25
- const arch = archMap[process.arch];
26
-
27
- if (!os || !arch) {
28
- throw new Error(
29
- `Unsupported platform/arch for varlock: ${process.platform}/${process.arch}. ` +
30
- `Supported: linux/x64, linux/arm64, darwin/x64, darwin/arm64.`,
31
- );
32
- }
33
-
34
- return `varlock-${os}-${arch}.tar.gz`;
35
- }
36
-
37
- /**
38
- * Co-locate a schema and env file in a temp directory so varlock can discover them.
39
- */
40
- export async function prepareVarlockDir(schemaPath: string, envPath: string): Promise<string> {
41
- const dir = await mkdtemp(join(tmpdir(), 'varlock-'));
42
- await copyFile(schemaPath, join(dir, '.env.schema'));
43
- await copyFile(envPath, join(dir, '.env'));
44
- return dir;
45
- }
46
-
47
- /**
48
- * Downloads varlock binary and caches it in ~/.cache/openpalm/bin/.
49
- * Skips download if binary already exists.
50
- */
51
- export async function ensureVarlock(): Promise<string> {
52
- const { resolveCacheHome } = await import('@openpalm/lib');
53
- const binDir = join(resolveCacheHome(), 'bin');
54
- const varlockBin = join(binDir, 'varlock');
55
-
56
- if (await Bun.file(varlockBin).exists()) {
57
- return varlockBin;
58
- }
59
-
60
- await mkdir(binDir, { recursive: true });
61
-
62
- const artifact = varlockArtifactName();
63
- const expectedHash = VARLOCK_CHECKSUMS[artifact];
64
- if (!expectedHash) {
65
- throw new Error(
66
- `No SHA-256 checksum on record for ${artifact}. ` +
67
- `Cannot verify download integrity.`,
68
- );
69
- }
70
-
71
- const tarballUrl = `https://github.com/dmno-dev/varlock/releases/download/varlock%40${VARLOCK_VERSION}/${artifact}`;
72
- const tarballPath = join(binDir, 'varlock.tar.gz');
73
-
74
- const response = await fetch(tarballUrl, { signal: AbortSignal.timeout(60_000) });
75
- if (!response.ok) {
76
- throw new Error(`Failed to download varlock tarball (HTTP ${response.status} ${response.statusText})`);
77
- }
78
- await Bun.write(tarballPath, response);
79
-
80
- const hasher = new Bun.CryptoHasher('sha256');
81
- hasher.update(await Bun.file(tarballPath).arrayBuffer());
82
- const actualHash = hasher.digest('hex');
83
- if (actualHash !== expectedHash) {
84
- try { await unlink(tarballPath); } catch { /* best effort */ }
85
- throw new Error(
86
- `varlock tarball SHA-256 verification failed — download may be corrupted.\n` +
87
- ` Expected: ${expectedHash}\n` +
88
- ` Actual: ${actualHash}`,
89
- );
90
- }
91
-
92
- const extractProc = Bun.spawn(
93
- ['tar', 'xzf', tarballPath, '--strip-components=1', '-C', binDir],
94
- {
95
- env: { ...process.env, HOME: process.env.HOME ?? '' },
96
- stdout: 'inherit',
97
- stderr: 'inherit',
98
- },
99
- );
100
- const extractCode = await extractProc.exited;
101
- if (extractCode !== 0) {
102
- throw new Error(`Failed to extract varlock tarball (tar exited with code ${extractCode})`);
103
- }
104
-
105
- try { await unlink(tarballPath); } catch { /* best effort */ }
106
-
107
- const chmodProc = Bun.spawn(['chmod', '+x', varlockBin]);
108
- const chmodCode = await chmodProc.exited;
109
- if (chmodCode !== 0) {
110
- throw new Error(`chmod +x failed for varlock binary (exit code ${chmodCode})`);
111
- }
112
-
113
- // macOS: clear quarantine flag and ad-hoc codesign so Gatekeeper does not kill the binary
114
- if (process.platform === 'darwin') {
115
- const xattr = Bun.spawn(['xattr', '-cr', varlockBin], { stdout: 'ignore', stderr: 'ignore' });
116
- await xattr.exited;
117
- const codesign = Bun.spawn(['codesign', '--force', '--sign', '-', varlockBin], { stdout: 'ignore', stderr: 'ignore' });
118
- await codesign.exited;
119
- }
120
-
121
- if (!(await Bun.file(varlockBin).exists())) {
122
- throw new Error(`varlock binary not found at ${varlockBin} after install`);
123
- }
124
-
125
- return varlockBin;
126
- }