@wenrwa/marketplace-cli 0.8.7 → 0.8.9

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.
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * A session anchor must not need a git identity.
5
+ *
6
+ * Production 2026-10-19 (owner: "the Changes button doesn't seem to work"):
7
+ * a hosted box has NO git identity — no user.name/user.email in any config;
8
+ * the agent's identity rides only in the PTY env (GIT_AUTHOR_*). The bridge's
9
+ * own `git commit-tree` therefore died with "Author identity unknown" (exit
10
+ * 128, probed on the live machine), no refs/wenrwa/session-* ref was ever
11
+ * created, and no session-scoped Changes report has ever been persisted (zero
12
+ * `scope = 'session'` rows in terminal_changes).
13
+ *
14
+ * This runs REAL git with `user.useConfigOnly = true` and no identity, which
15
+ * is the container's situation made deterministic (a laptop would otherwise
16
+ * auto-detect user@host and hide the bug).
17
+ */
18
+ const fs_1 = require("fs");
19
+ const os_1 = require("os");
20
+ const path_1 = require("path");
21
+ const child_process_1 = require("child_process");
22
+ const sessionAnchor_1 = require("../bridge/sessionAnchor");
23
+ const changesReporter_1 = require("../bridge/changesReporter");
24
+ // Jest's sandboxed process.env does not reach child_process on its own, so
25
+ // every git call here passes the (identity-less) env explicitly — the same
26
+ // merge execGit does in production.
27
+ const sandboxEnv = () => ({ ...process.env });
28
+ const run = (cmd, args, opts) => (0, changesReporter_1.execGit)(cmd, args, { env: { ...sandboxEnv(), ...(opts?.env ?? {}) } });
29
+ const git = (args) => (0, child_process_1.execFileSync)('git', args, { env: sandboxEnv(), stdio: 'pipe' }).toString().trim();
30
+ const IDENTITY_KEYS = ['GIT_AUTHOR_NAME', 'GIT_AUTHOR_EMAIL', 'GIT_COMMITTER_NAME', 'GIT_COMMITTER_EMAIL', 'EMAIL'];
31
+ describe('createSessionAnchor on a box with no git identity', () => {
32
+ let dir;
33
+ let repo;
34
+ const saved = {};
35
+ beforeEach(() => {
36
+ dir = (0, fs_1.mkdtempSync)((0, path_1.join)((0, os_1.tmpdir)(), 'anchor-noid-'));
37
+ repo = (0, path_1.join)(dir, 'repo');
38
+ const globalCfg = (0, path_1.join)(dir, 'gitconfig');
39
+ (0, fs_1.writeFileSync)(globalCfg, '[user]\n\tuseConfigOnly = true\n');
40
+ for (const k of [...IDENTITY_KEYS, 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_NOSYSTEM', 'HOME'])
41
+ saved[k] = process.env[k];
42
+ for (const k of IDENTITY_KEYS)
43
+ delete process.env[k];
44
+ process.env.GIT_CONFIG_GLOBAL = globalCfg;
45
+ process.env.GIT_CONFIG_NOSYSTEM = '1';
46
+ process.env.HOME = dir;
47
+ git(['init', '-q', repo]);
48
+ (0, fs_1.writeFileSync)((0, path_1.join)(repo, 'a.txt'), 'hello\n');
49
+ });
50
+ afterEach(() => {
51
+ for (const [k, v] of Object.entries(saved)) {
52
+ if (v === undefined)
53
+ delete process.env[k];
54
+ else
55
+ process.env[k] = v;
56
+ }
57
+ (0, fs_1.rmSync)(dir, { recursive: true, force: true });
58
+ });
59
+ it('git itself refuses a commit here (the precondition is real)', () => {
60
+ const tree = git(['-C', repo, 'write-tree']);
61
+ expect(() => git(['-C', repo, 'commit-tree', tree, '-m', 'x'])).toThrow(/identity unknown|auto-detection is disabled/i);
62
+ });
63
+ it('still creates the anchor commit and its ref', async () => {
64
+ const res = await (0, sessionAnchor_1.createSessionAnchor)(repo, 'sess-noid', { run });
65
+ expect(res.ok).toBe(true);
66
+ if (!res.ok)
67
+ return;
68
+ const refSha = git(['-C', repo, 'rev-parse', (0, sessionAnchor_1.anchorRefName)('sess-noid')]);
69
+ expect(refSha).toBe(res.sha);
70
+ // The anchor snapshots the untracked file, so a later diff is relative to it.
71
+ const files = git(['-C', repo, 'ls-tree', '--name-only', res.sha]);
72
+ expect(files).toBe('a.txt');
73
+ });
74
+ });
@@ -68,9 +68,17 @@ describe('createSessionAnchor', () => {
68
68
  expect(calls[1].args).toEqual(['-C', CWD, 'write-tree']);
69
69
  expect(calls[1].opts?.env?.GIT_INDEX_FILE).toBe(indexFile);
70
70
  // 3. commit-tree — no parent (a fresh anchor, not a chain), no index
71
- // involved at all (commit-tree never reads one) — no env override.
71
+ // involved at all (commit-tree never reads one). Its only env is a
72
+ // fixed identity: a hosted box has none (see
73
+ // bridge-session-anchor-no-identity.test.ts).
72
74
  expect(calls[2].args).toEqual(['-C', CWD, 'commit-tree', 'treesha0000', '-m', 'wenrwa session anchor']);
73
- expect(calls[2].opts?.env).toBeUndefined();
75
+ expect(calls[2].opts?.env).toEqual({
76
+ GIT_AUTHOR_NAME: 'HiveOp',
77
+ GIT_AUTHOR_EMAIL: 'session-anchor@hiveop.local',
78
+ GIT_COMMITTER_NAME: 'HiveOp',
79
+ GIT_COMMITTER_EMAIL: 'session-anchor@hiveop.local',
80
+ });
81
+ expect(calls[2].opts?.env?.GIT_INDEX_FILE).toBeUndefined();
74
82
  // 4. update-ref — points the session's ref at the new commit, no env.
75
83
  expect(calls[3].args).toEqual(['-C', CWD, 'update-ref', 'refs/wenrwa/session-sess-1', 'commitsha1111']);
76
84
  expect(calls[3].opts?.env).toBeUndefined();
@@ -0,0 +1,26 @@
1
+ import { type RepoEnvDeps } from './repoEnv';
2
+ export declare const ENV_NAMES_CAPABILITY = "env-names";
3
+ /** More than this is not a hand-kept key file; the list is capped, never a value leak. */
4
+ export declare const MAX_ENV_NAMES = 500;
5
+ export type EnvNamesRefusal = 'no-folder' | 'not-a-file' | 'too-large' | 'unreadable';
6
+ export type EnvNamesResult = {
7
+ ok: true;
8
+ names: string[];
9
+ } | {
10
+ ok: false;
11
+ reason: EnvNamesRefusal;
12
+ };
13
+ /**
14
+ * Names in `<root>/.env`, sorted, deduplicated. `{ok:true, names:[]}` when
15
+ * there is no `.env` (nothing saved yet). A symlinked `.env` is not followed.
16
+ */
17
+ export declare function readEnvNames(root: string | null | undefined, deps?: RepoEnvDeps): EnvNamesResult;
18
+ /**
19
+ * The relay's `env-names` frame → its `env-names-result` reply. `requestId`
20
+ * is echoed so the backend can match concurrent reads. The reply is built
21
+ * from the names array alone, so no value can ride along.
22
+ */
23
+ export declare function handleEnvNamesFrame(frame: {
24
+ requestId?: unknown;
25
+ workspaceId?: unknown;
26
+ }, resolveRoot: (workspaceId: string) => string | null | undefined, deps?: RepoEnvDeps): Record<string, unknown>;
@@ -0,0 +1,114 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.MAX_ENV_NAMES = exports.ENV_NAMES_CAPABILITY = void 0;
37
+ exports.readEnvNames = readEnvNames;
38
+ exports.handleEnvNamesFrame = handleEnvNamesFrame;
39
+ /**
40
+ * tp-02 (docs/prd/team-preview.md story 39) — the NAMES in a workspace's
41
+ * `.env`, never the values.
42
+ *
43
+ * Workspace settings shows a "Keys" list so a person can check what they have
44
+ * saved on their own machine. Values never leave the machine: the file is
45
+ * parsed here with the SAME parser terminals load it with (repoEnv.ts), the
46
+ * values are dropped on the spot, and only the names go into any reply.
47
+ *
48
+ * Both lanes share this module so they cannot drift:
49
+ * - hosted: the relay's `env-names` frame (relayClient.ts) → `env-names-result`
50
+ * - local: GET /local-env-names/:workspaceId on the loopback bridge (server.ts)
51
+ *
52
+ * tp-14 reuses the same reply to warn the owner about browser-visible names
53
+ * (NEXT_PUBLIC_* etc.) before sharing a preview.
54
+ *
55
+ * Never logs. Never throws.
56
+ */
57
+ const path = __importStar(require("path"));
58
+ const repoEnv_1 = require("./repoEnv");
59
+ exports.ENV_NAMES_CAPABILITY = 'env-names';
60
+ /** More than this is not a hand-kept key file; the list is capped, never a value leak. */
61
+ exports.MAX_ENV_NAMES = 500;
62
+ /**
63
+ * Names in `<root>/.env`, sorted, deduplicated. `{ok:true, names:[]}` when
64
+ * there is no `.env` (nothing saved yet). A symlinked `.env` is not followed.
65
+ */
66
+ function readEnvNames(root, deps = repoEnv_1.realRepoEnvDeps) {
67
+ if (!root)
68
+ return { ok: false, reason: 'no-folder' };
69
+ const p = path.join(root, repoEnv_1.DOTENV_FILENAME);
70
+ let text;
71
+ try {
72
+ const st = deps.lstat(p);
73
+ if (!st)
74
+ return { ok: true, names: [] };
75
+ if (!st.isFile())
76
+ return { ok: false, reason: 'not-a-file' };
77
+ if (st.size > repoEnv_1.MAX_DOTENV_BYTES)
78
+ return { ok: false, reason: 'too-large' };
79
+ text = deps.readFile(p);
80
+ }
81
+ catch {
82
+ return { ok: false, reason: 'unreadable' };
83
+ }
84
+ // The values are parsed here and go out of scope with this function.
85
+ const names = Object.keys((0, repoEnv_1.parseDotenv)(text)).sort().slice(0, exports.MAX_ENV_NAMES);
86
+ return { ok: true, names };
87
+ }
88
+ /**
89
+ * The relay's `env-names` frame → its `env-names-result` reply. `requestId`
90
+ * is echoed so the backend can match concurrent reads. The reply is built
91
+ * from the names array alone, so no value can ride along.
92
+ */
93
+ function handleEnvNamesFrame(frame, resolveRoot, deps = repoEnv_1.realRepoEnvDeps) {
94
+ const requestId = typeof frame.requestId === 'string' ? frame.requestId : undefined;
95
+ const workspaceId = typeof frame.workspaceId === 'string' ? frame.workspaceId : undefined;
96
+ const head = {
97
+ t: 'env-names-result',
98
+ ...(requestId !== undefined ? { requestId } : {}),
99
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
100
+ };
101
+ if (!workspaceId)
102
+ return { ...head, ok: false, reason: 'no-folder' };
103
+ let root;
104
+ try {
105
+ root = resolveRoot(workspaceId);
106
+ }
107
+ catch {
108
+ root = null;
109
+ }
110
+ const result = readEnvNames(root, deps);
111
+ return result.ok
112
+ ? { ...head, ok: true, names: [...result.names] }
113
+ : { ...head, ok: false, reason: result.reason };
114
+ }
@@ -0,0 +1,136 @@
1
+ export type PreviewMode = 'built' | 'dev';
2
+ export type PreviewStatus = 'stopped'
3
+ /** An update is fetching / installing / building right now. */
4
+ | 'building' | 'running'
5
+ /** The newest sha failed to install or build. The last good one (if any) still serves. */
6
+ | 'build_failed'
7
+ /** The newest sha built but never answered on the port. The last good one (if any) still serves. */
8
+ | 'start_failed'
9
+ /** Nothing could be attempted (see `reason`). */
10
+ | 'failed';
11
+ export type PreviewFailReason = 'busy_other_workspace' | 'no_repo_url' | 'git_failed' | 'no_package_json' | 'no_start_script' | 'port_in_use'
12
+ /** The app listened beyond 127.0.0.1 and could not be forced to loopback. */
13
+ | 'unsupported_bind';
14
+ export interface PreviewResult {
15
+ workspaceId: string;
16
+ status: PreviewStatus;
17
+ mode: PreviewMode;
18
+ /** The sha this request tried to bring up (absent when it never got that far). */
19
+ sha?: string;
20
+ /** The sha serving on the preview port right now. */
21
+ runningSha?: string;
22
+ /** The newest sha that built successfully. */
23
+ builtSha?: string;
24
+ reason?: PreviewFailReason;
25
+ /** One short, masked sentence for the owner. Never a log dump. */
26
+ message?: string;
27
+ }
28
+ export interface PreviewGitCredential {
29
+ token: string;
30
+ repoFullName?: string;
31
+ }
32
+ export interface PreviewRequest {
33
+ workspaceId: string;
34
+ repoUrl?: string;
35
+ gitCredential?: PreviewGitCredential;
36
+ mode?: PreviewMode;
37
+ }
38
+ export interface PreviewRunnerOpts {
39
+ /** Root holding every workspace's preview slots, e.g. /data/previews. */
40
+ previewsBaseDir: string;
41
+ /** The owner's working copy for a workspace — read for its `.env` ONLY. */
42
+ workingCopyDir: (workspaceId: string) => string;
43
+ /** The one fixed preview port (tp-06's forwarder targets it). */
44
+ port: number;
45
+ /** The bridge's env; only PASSTHROUGH_ENV names are taken from it. Default process.env. */
46
+ baseEnv?: Record<string, string | undefined>;
47
+ /** How long a started server gets to answer on the port. */
48
+ readyTimeoutMs?: {
49
+ built: number;
50
+ dev: number;
51
+ };
52
+ probeIntervalMs?: number;
53
+ /** Bound on each install / build step. */
54
+ stepTimeoutMs?: number;
55
+ /** SIGTERM → SIGKILL grace when stopping a process group. */
56
+ killGraceMs?: number;
57
+ /** This machine's non-loopback addresses (the bind check). Default: os.networkInterfaces(). */
58
+ externalAddresses?: () => string[];
59
+ }
60
+ /** The only names a preview process takes from the bridge's own env. */
61
+ /** The only address a preview app may listen on. */
62
+ export declare const PREVIEW_BIND_HOST = "127.0.0.1";
63
+ export declare const PREVIEW_PASSTHROUGH_ENV: readonly ["PATH", "HOME", "USER", "LANG", "LC_ALL", "TZ", "TMPDIR", "SHELL"];
64
+ /**
65
+ * The preview env: allowlisted process names < the owner's repo `.env`
66
+ * (reserved names already removed by loadRepoEnv) < the fixed PORT.
67
+ * Pure, so the "bridge secrets never reach the preview" rule is testable.
68
+ */
69
+ export declare function buildPreviewEnv(baseEnv: Record<string, string | undefined>, repoEnv: Record<string, string>, port: number): Record<string, string>;
70
+ /**
71
+ * Extra args that pin a known framework's server to loopback, appended after
72
+ * `--` to `npm run <script>`. Only for scripts whose command IS that
73
+ * framework's server — appending flags to an arbitrary script could break it.
74
+ * Anything else relies on HOST/HOSTNAME and the runtime bind check.
75
+ */
76
+ export declare function loopbackArgs(scriptCommand: string): string[];
77
+ /** Non-loopback addresses of this machine (IPv4 + IPv6, link-local scoped). */
78
+ export declare function machineExternalAddresses(): string[];
79
+ /** True when ANY non-loopback address answers on the port, i.e. the app is exposed. */
80
+ export declare function listensBeyondLoopback(port: number, addresses: string[], timeoutMs?: number): Promise<boolean>;
81
+ /** `ref: refs/heads/main\tHEAD` → `main`; null when the remote names no HEAD. */
82
+ export declare function parseSymrefHead(lsRemoteOutput: string): string | null;
83
+ /** Which install command a checkout wants, by lockfile. */
84
+ export declare function installCommand(files: (name: string) => boolean): [string, string[]];
85
+ export declare class PreviewRunner {
86
+ private readonly opts;
87
+ private readonly states;
88
+ /** One port, so one operation at a time across the whole box. */
89
+ private chain;
90
+ private orphanSwept;
91
+ constructor(opts: PreviewRunnerOpts);
92
+ /** Bring the preview up. Already serving this workspace in this mode → answered as-is. */
93
+ start(req: PreviewRequest): Promise<PreviewResult>;
94
+ /** Fetch the default branch again and, when it moved, rebuild and swap. */
95
+ update(req: PreviewRequest): Promise<PreviewResult>;
96
+ stop(req: {
97
+ workspaceId: string;
98
+ }): Promise<PreviewResult>;
99
+ /** The masked tail of this workspace's preview log. Owner-scoped by the caller. */
100
+ logs(req: {
101
+ workspaceId: string;
102
+ }): {
103
+ log: string;
104
+ truncated: boolean;
105
+ };
106
+ status(workspaceId: string): PreviewResult;
107
+ /** Stop every preview this runner started (bridge shutdown). Exact PIDs only. */
108
+ shutdown(): Promise<void>;
109
+ private serialize;
110
+ private snapshot;
111
+ private wsDir;
112
+ private logPath;
113
+ private pidfilePath;
114
+ private clearPidfile;
115
+ /**
116
+ * A bridge restart loses `states` but not the detached server it started,
117
+ * which would keep the port. Stop it by the exact PID this module recorded —
118
+ * only if that PID still leads a process group we would have created.
119
+ */
120
+ private sweepOrphans;
121
+ private otherServingWorkspace;
122
+ private deploy;
123
+ /** Bring the previous slot back after a failed swap, if there was one. */
124
+ private restoreSlot;
125
+ /** The request's own verdict, plus what is actually serving now. */
126
+ private withServing;
127
+ private fail;
128
+ private syncSlot;
129
+ private runStep;
130
+ private spawnServer;
131
+ private waitReady;
132
+ /** Stop the process GROUP this module spawned — by its exact leader PID. */
133
+ private killChild;
134
+ }
135
+ /** Any HTTP answer on 127.0.0.1:port counts as "up". */
136
+ export declare function probeOnce(port: number, timeoutMs?: number): Promise<boolean>;