CtrlK
BlogDocsLog inGet started
Tessl Logo

ai-ecoverse/advanced-skills

Quarantined high-risk skills (browser session capture, WebSocket interception) for Slack and Microsoft Teams. Install only after reviewing the security implications.

76

Quality

96%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide
SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

slack-ext.test.jsskills/slack/tests/

// Tests for skills/slack/scripts/slack-ext.jsh
//
// Run with:
//   tst <path-to-this-file>
//
// Strategy: compile the real source (removing the trailing `await main()` so
// it does not auto-execute), inject mock sliccy:* modules and a stub browser,
// then call the exported internal functions directly. This exercises the REAL
// code — no duplicated reimplementation that could silently diverge.
//
// Mutation verification is documented inline: each test names the mutation that
// would break it, and the section at the bottom records the verification matrix.

import test, { is, ok, not, fail } from 'tst';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import * as _argvMod from '../scripts/argv.js';
import * as _manifestDiffMod from '../scripts/manifest-diff.js';
import * as _gridMod from '../scripts/slack-ext-grid.js';

const SCRIPT = fileURLToPath(new URL('../scripts/slack-ext.jsh', import.meta.url));
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;

// ── Test harness ──────────────────────────────────────────────────────────────

class NodeExitError extends Error {
  constructor(message, code) {
    super(message);
    this.name = 'NodeExitError';
    this.exitCode = code !== undefined ? code : 1;
  }
}

/**
 * Load the slack-ext.jsh module with mocked dependencies and simulated argv.
 *
 * @param {object} opts
 * @param {string[]} opts.argv     process.argv (after 'node script') words
 * @param {object}  [opts.user]   users.info response body (merged with defaults)
 * @param {object}  [opts.convs]  users.conversations response body
 * @param {object}  [opts.adminResult]  users.admin.* response body (default ok:true)
 * @param {object}  [opts.inviteResult] conversations.invite response body
 * @param {object}  [opts.kickResult]   conversations.kick response body
 * @param {string}  [opts.tabUrl] override the Slack tab URL
 * @param {function} [opts.api]   (method, params) => body | undefined. Consulted
 *                                first; undefined falls through to the defaults.
 * @param {boolean} [opts.noTab]  browser.findTab finds no Slack tab.
 * @param {boolean} [opts.fakeTimers] replace setTimeout inside the script with
 *                                one that fires at once and records each delay
 *                                in h.sleeps (the read-back waits 5 s per retry).
 */
async function load(opts) {
  const calls = [];
  const sleeps = [];
  const stdout = [];
  const stderr = [];

  // Default workspace in tab URL (auto-detection path)
  const tabUrl = opts.tabUrl || 'https://app.slack.com/client/T06DUTYDQ/C000';

  // Default user: a regular member (not bot, not restricted)
  const defaultUser = {
    id: 'U12345',
    name: 'testuser',
    real_name: 'Test User',
    is_bot: false,
    is_restricted: false,
    is_ultra_restricted: false,
    deleted: false,
    profile: { display_name: 'testuser' },
  };
  const resolvedUser = Object.assign({}, defaultUser, opts.user || {});

  const fakeTab = { id: 'tab1', url: tabUrl };

  const browserStub = {
    async findTab() {
      return opts.noTab ? null : fakeTab;
    },
    async localStorage(tab, key) {
      if (key === 'localConfig_v2') {
        return JSON.stringify({
          teams: {
            T06DUTYDQ: { token: 'xoxc-test-token' },
            // Org-level token, used by the Enterprise Grid (eg-*) commands.
            E06V3987PMY: { token: 'xoxc-test-org-token' },
          },
        });
      }
      return null;
    },
    async fetch(tab, url, fetchOpts) {
      const method = url.replace('/api/', '');
      const params = {};
      if (fetchOpts && typeof fetchOpts.body === 'string') {
        for (const [k, v] of new URLSearchParams(fetchOpts.body)) if (k !== 'token') params[k] = v;
      }
      calls.push({ method, opts: fetchOpts, params });

      if (opts.api) {
        const custom = opts.api(method, params);
        if (custom !== undefined) return { body: custom };
      }

      // users.info
      if (method === 'users.info') {
        return { body: { ok: true, user: resolvedUser } };
      }
      // users.conversations (for status guest channels)
      if (method === 'users.conversations') {
        if (opts.convs) return { body: opts.convs };
        return { body: { ok: true, channels: [] } };
      }
      // users.admin.setUltraRestricted
      if (method === 'users.admin.setUltraRestricted') {
        return { body: opts.adminResult || { ok: true } };
      }
      // users.admin.setRestricted
      if (method === 'users.admin.setRestricted') {
        return { body: opts.adminResult || { ok: true } };
      }
      // users.admin.setRegular
      if (method === 'users.admin.setRegular') {
        return { body: opts.adminResult || { ok: true } };
      }
      // enterprise.users.admin.* (Grid writes)
      if (method.startsWith('enterprise.users.admin.')) {
        return { body: opts.adminResult || { ok: true } };
      }
      // conversations.invite
      if (method === 'conversations.invite') {
        return { body: opts.inviteResult || { ok: true } };
      }
      // conversations.kick
      if (method === 'conversations.kick') {
        return { body: opts.kickResult || { ok: true } };
      }
      return { body: { ok: false, error: 'not_mocked' } };
    },
  };

  const cliStub = {
    die(message, options) {
      const code = options && typeof options === 'object' && options.exitCode !== undefined ? options.exitCode : 1;
      throw new NodeExitError(String(message), code);
    },
    help(message) {
      stdout.push(String(message));
      throw new NodeExitError('help', 0);
    },
    out(value) {
      stdout.push(typeof value === 'string' ? value : JSON.stringify(value, null, 2));
    },
    warn(message) {
      stderr.push(String(message));
    },
  };

  const colorStub = new Proxy(
    {},
    {
      get: () => (s) => String(s),
    }
  );

  const mocks = {
    'sliccy:browser': browserStub,
    'sliccy:cli': cliStub,
    'sliccy:color': colorStub,
    'sliccy:exec': { exec: async () => ({ exitCode: 0, stdout: '', stderr: '' }) },
    // The `app` manifest subcommands (added later on the same branch) require
    // sliccy:http and sliccy:skill at module scope. They are stubbed to THROW if
    // a user-management command ever reaches them: these commands must keep
    // going through browser.fetch with the xoxc session token, never through the
    // bearer-token App Manifest client. See tests/slack-ext-app.test.js for the
    // app-command harness.
    'sliccy:http': {
      client: () => ({
        post: async () => {
          throw new Error('user-management commands must not use the App Manifest HTTP client');
        },
      }),
    },
    'sliccy:skill': { config: async () => null },
    fs: {
      readFile: async () => {
        throw new Error('ENOENT');
      },
      writeFile: async () => {
        throw new Error('EACCES');
      },
    },
  };

  // Relative specifiers (./argv.js, ./manifest-diff.js, ./slack-ext-grid.js) are
  // pre-loaded via static ESM imports above (the tst realm resolves imports statically
  // but its createRequire shim does not resolve relative file-system paths).
  const relativeModules = {
    './argv.js': () => (_argvMod.default || _argvMod),
    './manifest-diff.js': () => (_manifestDiffMod.default || _manifestDiffMod),
    './slack-ext-grid.js': () => (_gridMod.default || _gridMod)
  };
  const scriptRequire = (id) => {
    const key = id.replace(/^\.\.\/(scripts\/)?/, './');
    if (Object.prototype.hasOwnProperty.call(relativeModules, key)) return relativeModules[key]();
    throw new Error('unexpected relative require(' + id + ') — add a static import');
  };
  const mockRequire = (id) => {
    if (Object.prototype.hasOwnProperty.call(mocks, id)) return mocks[id];
    if (id.startsWith('./') || id.startsWith('../')) return scriptRequire(id);
    throw new Error('unexpected require(' + id + ')');
  };

  // Strip the trailing `await main()` so the module does not auto-execute
  let source = readFileSync(SCRIPT, 'utf8');
  // Anchor on the actual trailer (`try { await main(); }`), NOT on the first
  // top-level `try {` in the file. The greedy form truncated the module at the
  // first top-level try block, which silently discarded ~1800 lines and made
  // every test fail with '<fn> is not defined'.
  // opts.runMain compiles the file UNMODIFIED, so main() runs exactly as it does
  // when a user types the command. That is the only mode that can see a
  // top-level ordering bug such as a const read in its temporal dead zone.
  if (!opts.runMain) {
  source = source.replace(/\ntry \{\s*\n\s*await main\(\);[\s\S]*$/, '\n');

  // Append exports of the key internal functions for direct testing
  source += `
return {
  parseArgv,
  userTypeLabel,
  buildSetUltraRestrictedParams,
  buildSetRestrictedParams,
  buildSetRegularParams,
  cmdStatus,
  cmdSetSingle,
  cmdSetMulti,
  cmdSetMember,
  cmdAddChannel,
  cmdRemoveChannel,
  cmdEgStatus,
  cmdEgSetRestricted,
};
`;
  }

  const mockProcess = {
    argv: ['node', SCRIPT, ...opts.argv],
    env: {},
    exit: (code) => {
      throw new NodeExitError('exit', code);
    },
  };

  const mockConsole = {
    log: (msg) => stdout.push(String(msg === undefined ? '' : msg)),
    error: (msg) => stderr.push(String(msg === undefined ? '' : msg)),
    warn: (msg) => stderr.push(String(msg === undefined ? '' : msg)),
  };

  const scriptSetTimeout = opts.fakeTimers
    ? (fn, ms) => {
        sleeps.push(ms);
        Promise.resolve().then(fn);
        return 0;
      }
    : setTimeout;
  const factory = new AsyncFunction('require', 'process', 'console', 'setTimeout', source);
  let mod = null;
  let runError = null;
  try {
    mod = await factory(mockRequire, mockProcess, mockConsole, scriptSetTimeout);
  } catch (e) {
    if (!opts.runMain) throw e;
    runError = e;
  }

  return {
    mod,
    runError,
    calls,
    sleeps,
    stdout,
    stderr,
    text: () => stdout.join('\n'),
    errText: () => stderr.join('\n'),
    apiCalls: () => calls.map((c) => c.method),
    adminCalls: () => calls.filter((c) => c.method.startsWith('users.admin.')),
    mutatingCalls: () =>
      calls.filter(
        (c) =>
          c.method.startsWith('users.admin.') ||
          c.method === 'conversations.invite' ||
          c.method === 'conversations.kick'
      ),
  };
}

// ── Pure function tests (no browser mocks needed, extracted directly) ─────────

test('userTypeLabel: regular member', async () => {
  const h = await load({ argv: ['status', 'U1'] });
  const { userTypeLabel } = h.mod;
  is(userTypeLabel({ is_bot: false, is_restricted: false, is_ultra_restricted: false, deleted: false }), 'regular');
});

test('userTypeLabel: multi-channel guest', async () => {
  const h = await load({ argv: ['status', 'U1'] });
  const { userTypeLabel } = h.mod;
  is(userTypeLabel({ is_bot: false, is_restricted: true, is_ultra_restricted: false, deleted: false }), 'multi-channel guest');
});

test('userTypeLabel: single-channel guest', async () => {
  const h = await load({ argv: ['status', 'U1'] });
  const { userTypeLabel } = h.mod;
  is(userTypeLabel({ is_bot: false, is_restricted: true, is_ultra_restricted: true, deleted: false }), 'single-channel guest');
});

test('userTypeLabel: bot', async () => {
  const h = await load({ argv: ['status', 'U1'] });
  const { userTypeLabel } = h.mod;
  is(userTypeLabel({ is_bot: true, is_restricted: false, is_ultra_restricted: false, deleted: false }), 'bot');
});

test('userTypeLabel: deactivated', async () => {
  const h = await load({ argv: ['status', 'U1'] });
  const { userTypeLabel } = h.mod;
  is(userTypeLabel({ is_bot: false, is_restricted: false, is_ultra_restricted: false, deleted: true }), 'deactivated');
});

// ── Parameter name tests — these are the most safety-critical ─────────────────

test('buildSetUltraRestrictedParams uses "channel" (singular), not "channels"', async () => {
  const h = await load({ argv: ['status', 'U1'] });
  const { buildSetUltraRestrictedParams } = h.mod;
  const params = buildSetUltraRestrictedParams('U123', 'C456', 'T789');

  // CRITICAL: the API requires `channel` (singular). Passing `channels` returns
  // invalid_arguments. Verified live 2026-09-18.
  is(params.channel, 'C456', 'must use "channel" (singular)');
  is(params.channels, undefined, 'must NOT have "channels" (plural)');
  is(params.user, 'U123');
  is(params.team_id, 'T789');
});

test('buildSetRestrictedParams includes user and team_id', async () => {
  const h = await load({ argv: ['status', 'U1'] });
  const { buildSetRestrictedParams } = h.mod;
  const params = buildSetRestrictedParams('U123', 'T789');
  is(params.user, 'U123');
  is(params.team_id, 'T789');
  // setRestricted does NOT take a channel parameter
  is(params.channel, undefined);
});

test('buildSetRegularParams includes user and team_id', async () => {
  const h = await load({ argv: ['status', 'U1'] });
  const { buildSetRegularParams } = h.mod;
  const params = buildSetRegularParams('U123', 'T789');
  is(params.user, 'U123');
  is(params.team_id, 'T789');
  is(params.channel, undefined);
});

// ── --confirm guard tests ─────────────────────────────────────────────────────

test('set-single without --confirm makes no admin API call', async () => {
  let h;
  try {
    h = await load({
      argv: ['--ws=T06DUTYDQ', 'set-single', 'U12345', '--channel=C456'],
    });
    await h.mod.cmdSetSingle();
  } catch (e) {
    if (e.name === 'NodeExitError' && e.exitCode === 0) { /* ok */ } else throw e;
  }
  is(h.adminCalls().length, 0, 'no admin API call without --confirm');
  const text = h.text();
  ok(/no --confirm|nothing changed/i.test(text), 'should mention --confirm required');
});

test('set-multi without --confirm makes no admin API call', async () => {
  let h;
  try {
    h = await load({ argv: ['--ws=T06DUTYDQ', 'set-multi', 'U12345'] });
    await h.mod.cmdSetMulti();
  } catch (e) {
    if (e.name === 'NodeExitError' && e.exitCode === 0) { /* ok */ } else throw e;
  }
  is(h.adminCalls().length, 0, 'no admin API call without --confirm');
});

test('set-member without --confirm makes no admin API call', async () => {
  let h;
  try {
    h = await load({
      argv: ['--ws=T06DUTYDQ', 'set-member', 'U12345'],
      user: { is_restricted: true },
    });
    await h.mod.cmdSetMember();
  } catch (e) {
    if (e.name === 'NodeExitError' && e.exitCode === 0) { /* ok */ } else throw e;
  }
  is(h.adminCalls().length, 0, 'no admin API call without --confirm');
});

test('add-channel without --confirm makes no mutating API call', async () => {
  let h;
  try {
    h = await load({ argv: ['--ws=T06DUTYDQ', 'add-channel', 'U12345', '--channel=C456'] });
    await h.mod.cmdAddChannel();
  } catch (e) {
    if (e.name === 'NodeExitError' && e.exitCode === 0) { /* ok */ } else throw e;
  }
  is(h.mutatingCalls().length, 0, 'no mutating call without --confirm');
});

test('remove-channel without --confirm makes no mutating API call', async () => {
  let h;
  try {
    h = await load({ argv: ['--ws=T06DUTYDQ', 'remove-channel', 'U12345', '--channel=C456'] });
    await h.mod.cmdRemoveChannel();
  } catch (e) {
    if (e.name === 'NodeExitError' && e.exitCode === 0) { /* ok */ } else throw e;
  }
  is(h.mutatingCalls().length, 0, 'no mutating call without --confirm');
});

// ── Bot user rejection tests ──────────────────────────────────────────────────

test('set-single refuses bot user', async () => {
  let err;
  try {
    const h = await load({
      argv: ['--ws=T06DUTYDQ', 'set-single', 'U12345', '--channel=C456', '--confirm'],
      user: { is_bot: true, is_restricted: false, is_ultra_restricted: false, deleted: false },
    });
    await h.mod.cmdSetSingle();
  } catch (e) {
    err = e;
  }
  ok(err, 'should have thrown');
  is(err.name, 'NodeExitError');
  ok(/bot/i.test(err.message), 'error message should mention bot');
});

test('set-multi refuses bot user', async () => {
  let err;
  try {
    const h = await load({
      argv: ['--ws=T06DUTYDQ', 'set-multi', 'U12345', '--confirm'],
      user: { is_bot: true, is_restricted: false, is_ultra_restricted: false, deleted: false },
    });
    await h.mod.cmdSetMulti();
  } catch (e) {
    err = e;
  }
  ok(err, 'should have thrown');
  is(err.name, 'NodeExitError');
  ok(/bot/i.test(err.message), 'must match /bot/i');
});

test('set-member refuses bot user', async () => {
  let err;
  try {
    const h = await load({
      argv: ['--ws=T06DUTYDQ', 'set-member', 'U12345', '--confirm'],
      user: { is_bot: true, is_restricted: false, is_ultra_restricted: false, deleted: false },
    });
    await h.mod.cmdSetMember();
  } catch (e) {
    err = e;
  }
  ok(err, 'should have thrown');
  is(err.name, 'NodeExitError');
  ok(/bot/i.test(err.message), 'must match /bot/i');
});

// ── Already-in-state tests ────────────────────────────────────────────────────


test('set-multi is no-op when user is already a multi-channel guest', async () => {
  const h = await load({
    argv: ['--ws=T06DUTYDQ', 'set-multi', 'U12345', '--confirm'],
    user: { is_restricted: true, is_ultra_restricted: false, deleted: false, is_bot: false },
  });
  await h.mod.cmdSetMulti();
  is(h.adminCalls().length, 0, 'should not call setRestricted for already-MCG');
  ok(/no change needed/i.test(h.text()), 'must match /no change needed/i');
});

test('set-member is no-op when user is already a regular member', async () => {
  const h = await load({
    argv: ['--ws=T06DUTYDQ', 'set-member', 'U12345', '--confirm'],
    user: { is_restricted: false, is_ultra_restricted: false, deleted: false, is_bot: false },
  });
  await h.mod.cmdSetMember();
  is(h.adminCalls().length, 0, 'should not call setRegular for already-member');
  ok(/no change needed/i.test(h.text()), 'must match /no change needed/i');
});

// ── Confirmed mutation tests ──────────────────────────────────────────────────

test('set-single with --confirm calls users.admin.setUltraRestricted with channel (singular)', async () => {
  const h = await load({
    argv: ['--ws=T06DUTYDQ', 'set-single', 'U12345', '--channel=C456', '--confirm'],
  });
  await h.mod.cmdSetSingle();

  const adminCall = h.calls.find((c) => c.method === 'users.admin.setUltraRestricted');
  ok(adminCall, 'users.admin.setUltraRestricted must be called');

  // Verify the URLSearchParams body contains 'channel' (singular)
  const body = adminCall.opts && adminCall.opts.body;
  ok(body, 'call must have a body');
  ok(/channel=C456/.test(body), 'body must contain channel=C456');
  ok(!(/channels=/.test(body)), 'body must NOT contain channels= (plural)');
  ok(/user=U12345/.test(body), 'body must contain user=U12345');
  ok(/team_id=T06DUTYDQ/.test(body), 'body must contain team_id=T06DUTYDQ');
});

test('set-multi with --confirm calls users.admin.setRestricted', async () => {
  const h = await load({
    argv: ['--ws=T06DUTYDQ', 'set-multi', 'U12345', '--confirm'],
  });
  await h.mod.cmdSetMulti();

  const adminCall = h.calls.find((c) => c.method === 'users.admin.setRestricted');
  ok(adminCall, 'users.admin.setRestricted must be called');
  const body = adminCall.opts && adminCall.opts.body;
  ok(/user=U12345/.test(body), 'must match /user=U12345/');
  ok(/team_id=T06DUTYDQ/.test(body), 'must match /team_id=T06DUTYDQ/');
});

test('set-member with --confirm calls users.admin.setRegular', async () => {
  const h = await load({
    argv: ['--ws=T06DUTYDQ', 'set-member', 'U12345', '--confirm'],
    user: { is_restricted: true, is_ultra_restricted: false, deleted: false, is_bot: false },
  });
  await h.mod.cmdSetMember();

  const adminCall = h.calls.find((c) => c.method === 'users.admin.setRegular');
  ok(adminCall, 'users.admin.setRegular must be called');
  const body = adminCall.opts && adminCall.opts.body;
  ok(/user=U12345/.test(body), 'must match /user=U12345/');
  ok(/team_id=T06DUTYDQ/.test(body), 'must match /team_id=T06DUTYDQ/');
});

test('add-channel with --confirm calls conversations.invite', async () => {
  const h = await load({
    argv: ['--ws=T06DUTYDQ', 'add-channel', 'U12345', '--channel=C456', '--confirm'],
  });
  await h.mod.cmdAddChannel();

  const inviteCall = h.calls.find((c) => c.method === 'conversations.invite');
  ok(inviteCall, 'conversations.invite must be called');
  const body = inviteCall.opts && inviteCall.opts.body;
  ok(/channel=C456/.test(body), 'must match /channel=C456/');
  ok(/users=U12345/.test(body), 'must match /users=U12345/');
});

test('remove-channel with --confirm calls conversations.kick', async () => {
  const h = await load({
    argv: ['--ws=T06DUTYDQ', 'remove-channel', 'U12345', '--channel=C456', '--confirm'],
  });
  await h.mod.cmdRemoveChannel();

  const kickCall = h.calls.find((c) => c.method === 'conversations.kick');
  ok(kickCall, 'conversations.kick must be called');
  const body = kickCall.opts && kickCall.opts.body;
  ok(/channel=C456/.test(body), 'must match /channel=C456/');
  ok(/user=U12345/.test(body), 'must match /user=U12345/');
});

// ── parseArgv: BOOL_FLAGS includes confirm ────────────────────────────────────

test('parseArgv treats --confirm as a boolean flag (no value consumed)', async () => {
  const h = await load({ argv: ['status', 'U1'] });
  const { parseArgv } = h.mod;

  // Without --confirm
  const a = parseArgv(['set-single', 'U1', '--channel=C2', '--ws=T3']);
  is(a.flags.confirm, undefined);

  // With --confirm as the last flag (no next token)
  const b = parseArgv(['set-single', 'U1', '--channel=C2', '--ws=T3', '--confirm']);
  is(b.flags.confirm, true);

  // With --confirm followed by a positional — must not consume the positional as value
  const c = parseArgv(['set-single', '--confirm', 'U1']);
  is(c.flags.confirm, true);
  is(c.positional, ['set-single', 'U1']);
});

// ── Mutation test documentation ────────────────────────────────────────────────
//
// Each mutation and the test that catches it:
//
// MUTATION 1: Remove the `--confirm` guard (delete `if (!flags.confirm) { ... return; }`)
//   Caught by: "set-single without --confirm makes no admin API call"
//              "set-multi without --confirm makes no admin API call"
//              "set-member without --confirm makes no admin API call"
//              "add-channel without --confirm makes no mutating API call"
//              "remove-channel without --confirm makes no mutating API call"
//
// MUTATION 2: Change `channel` to `channels` in buildSetUltraRestrictedParams
//   Caught by: "buildSetUltraRestrictedParams uses "channel" (singular), not "channels""
//              "set-single with --confirm calls users.admin.setUltraRestricted with channel (singular)"
//
// MUTATION 3: Remove the bot check (delete `if (user.is_bot) { cli.die(...) }`)
//   Caught by: "set-single refuses bot user"
//              "set-multi refuses bot user"
//              "set-member refuses bot user"
//
// MUTATION 4: Remove the already-in-state check for set-single
//   Caught by: "set-single: guest already in B with --channel=B is a no-op"
//   NOTE: the original test here asserted the no-op from account type ALONE, with no
//   convs fixture, which is the very defect Codex finding 2 reported. It was removed;
//   the superseding test supplies the current channel and asserts the true no-op.
//
// MUTATION 5: Remove the already-in-state check for set-multi
//   Caught by: "set-multi is no-op when user is already a multi-channel guest"
//
// MUTATION 6: Remove the already-in-state check for set-member
//   Caught by: "set-member is no-op when user is already a regular member"
//
// MUTATION 7: Call wrong Slack method in set-single (e.g. setRestricted instead)
//   Caught by: "set-single with --confirm calls users.admin.setUltraRestricted with channel (singular)"
//
// MUTATION 8: Call wrong Slack method in set-multi (e.g. setRegular instead)
//   Caught by: "set-multi with --confirm calls users.admin.setRestricted"
//
// MUTATION 9: Call wrong Slack method in set-member (e.g. setRestricted instead)
//   Caught by: "set-member with --confirm calls users.admin.setRegular"
//
// VERIFICATION EVIDENCE (mutation tests run before filing the PR):
//   All mutations above were applied, the named test failed, and the mutation was reverted.

// ── Finding 1: --confirm=value bool normalization ─────────────────────────────

test('--confirm=false does NOT authorize a mutation (admin call must not happen)', async () => {
  // The string "false" is truthy; before the fix it bypassed the --confirm guard.
  // After the fix, --confirm=false is boolean false and the guard blocks the call.
  let h;
  try {
    h = await load({
      argv: ['--ws=T06DUTYDQ', 'set-single', 'U12345', '--channel=C456', '--confirm=false'],
    });
    await h.mod.cmdSetSingle();
  } catch (e) {
    if (e.name === 'NodeExitError' && e.exitCode === 0) {
      /* expected: dry-run exit */
    } else {
      throw e;
    }
  }
  is(
    h.adminCalls().length,
    0,
    '--confirm=false must NOT issue the admin API call'
  );
});

test('--confirm=false dry-run still mentions what would happen', async () => {
  let h;
  try {
    h = await load({
      argv: ['--ws=T06DUTYDQ', 'set-single', 'U12345', '--channel=C456', '--confirm=false'],
    });
    await h.mod.cmdSetSingle();
  } catch (e) {
    if (e.name !== 'NodeExitError' || e.exitCode !== 0) throw e;
  }
  ok(
    /would change|no --confirm|nothing changed/i.test(h.text()),
    'dry-run output must describe the would-be change'
  );
});

test('--confirm=true (equals form) DOES authorize the mutation', async () => {
  const h = await load({
    argv: ['--ws=T06DUTYDQ', 'set-single', 'U12345', '--channel=C456', '--confirm=true'],
  });
  await h.mod.cmdSetSingle();
  is(
    h.adminCalls().length,
    1,
    '--confirm=true must issue the admin API call'
  );
});

test('--confirm=fasle (typo) is a fatal error, not an authorization', async () => {
  // A typo in the value must never authorize a mutation. It must be a fatal
  // error so the operator knows the flag was not understood.
  let err;
  try {
    await load({
      argv: ['--ws=T06DUTYDQ', 'set-single', 'U12345', '--channel=C456', '--confirm=fasle'],
    });
  } catch (e) {
    err = e;
  }
  ok(err, 'should have thrown an error');
  is(err.name, 'NodeExitError', 'must exit non-zero');
  ok(err.exitCode !== 0, 'exit code must be non-zero');
});

test('parseArgv: --confirm=false stores false (not truthy string "false")', async () => {
  const h = await load({ argv: ['status', 'U1'] });
  const { parseArgv } = h.mod;
  const r = parseArgv(['set-single', 'U1', '--confirm=false']);
  is(r.flags.confirm, false, '--confirm=false must be stored as boolean false');
});

test('parseArgv: --confirm=true stores true', async () => {
  const h = await load({ argv: ['status', 'U1'] });
  const { parseArgv } = h.mod;
  const r = parseArgv(['set-single', 'U1', '--confirm=true']);
  is(r.flags.confirm, true, '--confirm=true must be stored as boolean true');
});

test('parseArgv: --confirm=yes, --confirm=1, --confirm=on all store true', async () => {
  const h = await load({ argv: ['status', 'U1'] });
  const { parseArgv } = h.mod;
  is(parseArgv(['x', '--confirm=yes']).flags.confirm, true);
  is(parseArgv(['x', '--confirm=1']).flags.confirm, true);
  is(parseArgv(['x', '--confirm=on']).flags.confirm, true);
});

test('parseArgv: --confirm=no, --confirm=0, --confirm=off all store false', async () => {
  const h = await load({ argv: ['status', 'U1'] });
  const { parseArgv } = h.mod;
  is(parseArgv(['x', '--confirm=no']).flags.confirm, false);
  is(parseArgv(['x', '--confirm=0']).flags.confirm, false);
  is(parseArgv(['x', '--confirm=off']).flags.confirm, false);
});

// ── Finding 2: set-single already-SCG channel comparison ─────────────────────

test('set-single: guest already in A with --channel=B attempts the change', async () => {
  // The guest is currently in C_OLD. We request C_NEW. This is a real change;
  // setUltraRestricted MUST be called.
  const h = await load({
    argv: ['--ws=T06DUTYDQ', 'set-single', 'U12345', '--channel=CNEW', '--confirm'],
    user: { is_restricted: true, is_ultra_restricted: true, deleted: false, is_bot: false },
    convs: { ok: true, channels: [{ id: 'COLD', name: 'old-channel' }] },
  });
  await h.mod.cmdSetSingle();
  is(
    h.adminCalls().length,
    1,
    'must call setUltraRestricted when the requested channel differs from the current one'
  );
  const adminCall = h.calls.find((c) => c.method === 'users.admin.setUltraRestricted');
  ok(adminCall, 'call must be setUltraRestricted');
  ok(/channel=CNEW/.test(adminCall.opts.body), 'must use the new channel ID');
});

test('set-single: guest already in B with --channel=B is a no-op', async () => {
  // The guest is already in the exact same channel. This is a true no-op.
  const h = await load({
    argv: ['--ws=T06DUTYDQ', 'set-single', 'U12345', '--channel=CSAME', '--confirm'],
    user: { is_restricted: true, is_ultra_restricted: true, deleted: false, is_bot: false },
    convs: { ok: true, channels: [{ id: 'CSAME', name: 'same-channel' }] },
  });
  await h.mod.cmdSetSingle();
  is(h.adminCalls().length, 0, 'must NOT call setUltraRestricted when channel unchanged');
  ok(/no change needed/i.test(h.text()), 'must say no change needed');
});

test('set-single: failed users.conversations lookup is fatal, not "no change needed"', async () => {
  // If we cannot determine the current channel, we must not claim anything about
  // the account state. Claiming "no change needed" on a failed API call is
  // dangerous — it could mask a real difference.
  let err;
  try {
    const h = await load({
      argv: ['--ws=T06DUTYDQ', 'set-single', 'U12345', '--channel=C456', '--confirm'],
      user: { is_restricted: true, is_ultra_restricted: true, deleted: false, is_bot: false },
      convs: { ok: false, error: 'enterprise_is_restricted' },
    });
    await h.mod.cmdSetSingle();
  } catch (e) {
    err = e;
  }
  ok(err, 'should have thrown');
  is(err.name, 'NodeExitError');
  // Must NOT print "no change needed" — that would be a false claim
  // (We can't check h.text() here since we don't have h in scope; the throw
  //  itself is the proof that the command did not silently succeed or no-op.)
});

test('set-single: channel-change path respects --confirm gate', async () => {
  // Even when a channel change is needed, the --confirm gate must block the call.
  let h;
  try {
    h = await load({
      argv: ['--ws=T06DUTYDQ', 'set-single', 'U12345', '--channel=CNEW'],
      user: { is_restricted: true, is_ultra_restricted: true, deleted: false, is_bot: false },
      convs: { ok: true, channels: [{ id: 'COLD', name: 'old-channel' }] },
    });
    await h.mod.cmdSetSingle();
  } catch (e) {
    if (e.name === 'NodeExitError' && e.exitCode === 0) {
      /* expected dry-run exit */
    } else {
      throw e;
    }
  }
  is(
    h.adminCalls().length,
    0,
    'channel-change path must still require --confirm'
  );
});

// ── Finding 3: cmdStatus users.conversations failure handling ─────────────────

test('status: failed users.conversations does NOT print "(none found)"', async () => {
  const h = await load({
    argv: ['--ws=T06DUTYDQ', 'status', 'U12345'],
    user: { is_restricted: true, is_ultra_restricted: false, deleted: false, is_bot: false },
    convs: { ok: false, error: 'enterprise_is_restricted' },
  });
  await h.mod.cmdStatus();
  const out = h.text();
  ok(
    !/(none found)/i.test(out),
    'must NOT print "(none found)" when the API call failed'
  );
});

test('status: failed users.conversations prints the actual Slack error code', async () => {
  const h = await load({
    argv: ['--ws=T06DUTYDQ', 'status', 'U12345'],
    user: { is_restricted: true, is_ultra_restricted: false, deleted: false, is_bot: false },
    convs: { ok: false, error: 'enterprise_is_restricted' },
  });
  await h.mod.cmdStatus();
  const out = h.text();
  ok(
    /enterprise_is_restricted/.test(out),
    'must print the actual Slack error code so the user knows what went wrong'
  );
});

test('status: success with zero channels prints a distinct empty-state message', async () => {
  const h = await load({
    argv: ['--ws=T06DUTYDQ', 'status', 'U12345'],
    user: { is_restricted: true, is_ultra_restricted: false, deleted: false, is_bot: false },
    convs: { ok: true, channels: [] },
  });
  await h.mod.cmdStatus();
  const out = h.text();
  ok(
    !/(none found)/i.test(out),
    'empty-success path must use a different message from the old "(none found)" bucket'
  );
  // Should say something about "none" or "no channel" but NOT "(none found)"
  ok(
    /none|no channel/i.test(out),
    'should still describe the empty state'
  );
});

test('status --json: failed users.conversations produces channels_error in output', async () => {
  const h = await load({
    argv: ['--ws=T06DUTYDQ', 'status', 'U12345', '--json'],
    user: { is_restricted: true, is_ultra_restricted: false, deleted: false, is_bot: false },
    convs: { ok: false, error: 'enterprise_is_restricted' },
  });
  await h.mod.cmdStatus();
  // Find the JSON line in stdout
  const jsonLine = h.stdout.find((l) => {
    try { JSON.parse(l); return true; } catch (e) { return false; }
  });
  ok(jsonLine, 'should have a JSON output line');
  const parsed = JSON.parse(jsonLine);
  ok(
    parsed.channels_error === 'enterprise_is_restricted',
    'channels_error must be present and equal to the Slack error code'
  );
});

test('status --json: success with channels produces channels array in output', async () => {
  const h = await load({
    argv: ['--ws=T06DUTYDQ', 'status', 'U12345', '--json'],
    user: { is_restricted: true, is_ultra_restricted: false, deleted: false, is_bot: false },
    convs: { ok: true, channels: [{ id: 'C111', name: 'chan1' }] },
  });
  await h.mod.cmdStatus();
  const jsonLine = h.stdout.find((l) => {
    try { JSON.parse(l); return true; } catch (e) { return false; }
  });
  ok(jsonLine, 'should have JSON output');
  const parsed = JSON.parse(jsonLine);
  ok(Array.isArray(parsed.channels), 'channels must be an array in JSON output');
  is(parsed.channels[0].id, 'C111', 'channel id must match');
});

// ── Updated mutation matrix (additions for the three findings) ────────────────
//
// MUTATION 10: In parseArgv, do NOT normalize BOOL_FLAGS in the --name=value branch
//   (i.e. keep `f[m[1]] = m[2]` for all names, even bool ones)
//   Caught by: "--confirm=false does NOT authorize a mutation"
//              "parseArgv: --confirm=false stores false (not truthy string)"
//
// MUTATION 11: In parseArgv, accept --confirm=fasle (typo) silently as true
//   Caught by: "--confirm=fasle (typo) is a fatal error"
//
// MUTATION 12: In set-single, keep the old "return immediately on is_ultra_restricted"
//   without channel comparison
//   Caught by: "set-single: guest already in A with --channel=B attempts the change"
//
// MUTATION 13: In set-single, call users.conversations but skip the fatal-error branch
//   for !scgConv.ok (treat it as no change needed instead)
//   Caught by: "set-single: failed users.conversations lookup is fatal"
//
// MUTATION 14: In cmdStatus, collapse !ok and ok+empty back into a single else branch
//   Caught by: "status: failed users.conversations does NOT print (none found)"
//              "status: failed users.conversations prints the actual Slack error code"
//
// MUTATION 15: In cmdStatus, omit channels_error from the JSON output on failure
//   Caught by: "status --json: failed users.conversations produces channels_error"
//
// VERIFICATION: mutations 10-15 were each applied, the named test was confirmed to fail,
// then the mutation was reverted. See report for details.

// ── Entry-point ordering (temporal dead zone) ──────────────────────────────────
//
// Every eg-* and channel-* command once died on invocation with
// "Cannot access 'ORG_ID' before initialization": the `await main()` trailer sat
// above the Grid section, so main() ran before that section's top-level consts
// were initialised. All other tests missed it because they strip from the trailer
// to EOF, which removed the Grid section before compiling. These tests close that.

const ENTRY_TRAILER = /\ntry \{\s*\n\s*await main\(\);\s*\n\} catch \(err\) \{[\s\S]*?\n\}\n/;

test('entry point: nothing but whitespace follows the await main() trailer', () => {
  const src = readFileSync(SCRIPT, 'utf8');
  const m = ENTRY_TRAILER.exec(src);
  ok(m, 'the await main() trailer must exist');
  const after = src.slice(m.index + m[0].length);
  is(after.trim(), '', 'no top-level statement may follow the trailer (found: ' + after.trim().slice(0, 60) + ')');
});

test('entry point: every top-level const/let is declared before main() runs', () => {
  const src = readFileSync(SCRIPT, 'utf8');
  const m = ENTRY_TRAILER.exec(src);
  ok(m, 'the await main() trailer must exist');
  const late = src.slice(m.index).split('\n').filter((l) => /^(const|let) /.test(l));
  is(late.length, 0, 'top-level declarations after the trailer: ' + late.join(' | '));
});

test('eg-status runs end to end from the real entry point', async () => {
  const h = await load({ runMain: true, argv: ['eg-status', 'U12345'] });
  const msg = h.runError ? String(h.runError.message) : '';
  ok(!/before initialization/.test(msg), 'must not hit a temporal dead zone: ' + msg);
  ok(!h.runError || h.runError.exitCode === 0, 'must not exit non-zero: ' + msg);
  ok(h.apiCalls().includes('users.info'), 'must call users.info; calls were ' + h.apiCalls().join(','));
  ok(/Enterprise user: U12345/.test(h.text()), 'must print the enterprise user section');
});

test('eg-set-restricted from the real entry point makes no write without --confirm', async () => {
  const h = await load({ runMain: true, argv: ['eg-set-restricted', 'U12345'] });
  const msg = h.runError ? String(h.runError.message) : '';
  ok(!/before initialization/.test(msg), 'must not hit a temporal dead zone: ' + msg);
  const writes = h.calls.filter((c) => c.method.startsWith('enterprise.users.admin.'));
  is(writes.length, 0, 'dry run must make zero enterprise.users.admin.* calls');
});

test('eg-set-restricted --confirm reaches the Grid write method', async () => {
  // Control for the test above: proves the dry-run assertion is not vacuously
  // true because the command never got far enough to write at all.
  const h = await load({ runMain: true, argv: ['eg-set-restricted', 'U12345', '--confirm'] });
  const msg = h.runError ? String(h.runError.message) : '';
  ok(!/before initialization/.test(msg), 'must not hit a temporal dead zone: ' + msg);
  ok(
    h.apiCalls().includes('enterprise.users.admin.setRestricted'),
    'with --confirm the write must be issued; calls were ' + h.apiCalls().join(',')
  );
});

test('the stripped harness now includes Grid code', async () => {
  const h = await load({ argv: ['eg-status', 'U12345'] });
  is(typeof h.mod.cmdEgStatus, 'function', 'cmdEgStatus must survive the strip');
  await h.mod.cmdEgStatus();
  ok(/Enterprise user: U12345/.test(h.text()), 'cmdEgStatus must print the enterprise user section');
});

// ── channel-archive / channel-unarchive through the real entry point ──────────
//
// runMain: the file is compiled UNMODIFIED and main() dispatches the command
// exactly as a user typing it would. opts.api answers the Slack methods;
// opts.fakeTimers makes the 5 s read-back delay instant and records it.

const ARCH_NOW = Date.now();
const archChan = (over) =>
  Object.assign(
    {
      id: 'C04633RSEDU',
      name: 'assets-adidas',
      is_private: true,
      is_archived: false,
      member_count: 6,
      external_user_count: 0,
      is_ext_shared: false,
      is_pending_ext_shared: false,
      is_org_shared: false,
      // 1282 days ago, in MICROSECONDS as on the wire
      last_activity_ts: (ARCH_NOW - 1282 * 86400000 - 3600000) * 1000,
    },
    over || {}
  );

// states: channel per admin.conversations.search call (the last one repeats).
function archApi(states, extra) {
  let n = 0;
  const x = extra || {};
  return (method, params) => {
    if (method === 'admin.conversations.search') {
      const st = states[Math.min(n, states.length - 1)];
      n += 1;
      return { ok: true, conversations: st ? [st] : [], next_cursor: '' };
    }
    if (method === 'conversations.info') return { ok: false, error: 'channel_not_found' };
    if (method === 'admin.conversations.archive' || method === 'admin.conversations.unarchive') {
      return x.writeResult || { ok: true };
    }
    return undefined;
  };
}

const ARCH_WRITES = ['admin.conversations.archive', 'admin.conversations.unarchive'];
const archWrites = (h) => h.calls.filter((c) => ARCH_WRITES.includes(c.method)).length;
const archSeq = (h) => h.apiCalls().join(',');
const exitOf = (h) => (h.runError ? h.runError.exitCode : 0);
const errOf = (h) => (h.runError ? String(h.runError.message) : '');

// --json contract: stdout is exactly ONE JSON document and nothing else. The
// whole of stdout must parse; a human line anywhere breaks the parse.
function onlyJson(h) {
  is(h.stdout.length, 1, 'stdout must hold exactly one write in --json mode, got ' + h.stdout.length + ': ' + JSON.stringify(h.stdout).slice(0, 300));
  const whole = h.stdout.join('\n');
  let parsed = null;
  try {
    parsed = JSON.parse(whole);
  } catch (e) {
    fail('entire stdout must parse as JSON: ' + e.message + ' -- stdout was: ' + whole.slice(0, 300));
  }
  ok(parsed && typeof parsed === 'object' && !Array.isArray(parsed), 'stdout must be a JSON object');
  return parsed;
}

test('channel-archive dry run (entry point): reads state, prints it, makes no write', async () => {
  const h = await load({ runMain: true, fakeTimers: true, argv: ['channel-archive', 'C04633RSEDU'], api: archApi([archChan()]) });
  is(errOf(h), '');
  is(exitOf(h), 0);
  is(archSeq(h), 'admin.conversations.search');
  is(archWrites(h), 0);
  const t = h.text();
  ok(/#assets-adidas \(C04633RSEDU\)/.test(t), 'prints name and id');
  ok(/Visibility:\s+private/.test(t), 'prints visibility');
  ok(/Archived:\s+no/.test(t), 'prints archived state');
  ok(/Members:\s+6/.test(t), 'prints member count');
  ok(/External:\s+0 users/.test(t), 'prints external users');
  ok(/Shared:\s+not ext-shared/.test(t), 'prints sharing');
  ok(/Last activity: \d{4}-\d{2}-\d{2} \(1282 days idle\)/.test(t), 'prints date and days idle');
  ok(/--confirm would: re-read this channel/.test(t), 'says what --confirm would do');
  ok(/indistinguishable from a direct human action/.test(t), 'attribution notice');
});

test('channel-archive dry run (entry point) with --max-members=2 shows the members-over-limit refusal', async () => {
  const h = await load({ runMain: true, argv: ['channel-archive', 'C04633RSEDU', '--max-members=2'], api: archApi([archChan()]) });
  is(exitOf(h), 0);
  is(archWrites(h), 0);
  ok(/--confirm would REFUSE: members-over-limit \(6 members > --max-members=2\)/.test(h.text()), h.text());
});

test('channel-archive --confirm (entry point): re-check, archive, read-back, confirmed', async () => {
  const h = await load({
    runMain: true,
    fakeTimers: true,
    argv: ['channel-archive', 'C04633RSEDU', '--confirm', '--max-members=10', '--min-idle-days=365'],
    api: archApi([archChan(), archChan({ is_archived: true, member_count: -1 })]),
  });
  is(errOf(h), '');
  is(archSeq(h), 'admin.conversations.search,admin.conversations.archive,admin.conversations.search');
  const w = h.calls.find((c) => c.method === 'admin.conversations.archive');
  is(JSON.stringify(w.params), '{"channel_id":"C04633RSEDU"}');
  ok(/archived \(confirmed\)/.test(h.text()));
});

test('channel-archive --confirm uses the ORG token, not a workspace token', async () => {
  const h = await load({
    runMain: true,
    fakeTimers: true,
    argv: ['channel-archive', 'C04633RSEDU', '--confirm'],
    api: archApi([archChan(), archChan({ is_archived: true })]),
  });
  const w = h.calls.find((c) => c.method === 'admin.conversations.archive');
  is(new URLSearchParams(w.opts.body).get('token'), 'xoxc-test-org-token');
});

test('channel-archive --confirm members-over-limit (entry point): exit 1, named, no write', async () => {
  const h = await load({ runMain: true, argv: ['channel-archive', 'C04633RSEDU', '--confirm', '--max-members=2'], api: archApi([archChan()]) });
  is(exitOf(h), 1);
  ok(/refused: members-over-limit/.test(errOf(h)), errOf(h));
  is(archWrites(h), 0);
});

test('channel-archive --confirm members-unknown (entry point): null count refused, no write', async () => {
  const h = await load({
    runMain: true,
    argv: ['channel-archive', 'C04633RSEDU', '--confirm', '--max-members=100'],
    api: archApi([archChan({ member_count: null })]),
  });
  is(exitOf(h), 1);
  ok(/refused: members-unknown/.test(errOf(h)), errOf(h));
  is(archWrites(h), 0);
});

test('channel-archive --confirm already-archived (entry point): exit 0, no write', async () => {
  const h = await load({
    runMain: true,
    argv: ['channel-archive', 'C0634KMGW2G', '--confirm'],
    api: archApi([archChan({ id: 'C0634KMGW2G', name: 'aem-axeno1', is_private: false, is_archived: true, member_count: -1 })]),
  });
  is(errOf(h), '');
  is(exitOf(h), 0);
  is(archSeq(h), 'admin.conversations.search');
  ok(/already-archived: nothing to do/.test(h.text()));
});

test('channel-archive --confirm read-back exhausted (entry point): unconfirmed, exit 3, 10 s apart', async () => {
  const h = await load({ runMain: true, fakeTimers: true, argv: ['channel-archive', 'C04633RSEDU', '--confirm'], api: archApi([archChan()]) });
  is(exitOf(h), 3);
  is(archWrites(h), 1);
  ok(/archived \(unconfirmed: search index did not reflect it after 10 attempts\)/.test(h.text()), h.text());
  is(h.sleeps.join(','), '10000,10000,10000,10000,10000,10000,10000,10000,10000');
});

test('channel-archive --confirm read-back lag (entry point): confirms on a later attempt', async () => {
  const h = await load({
    runMain: true,
    fakeTimers: true,
    argv: ['channel-archive', 'C04633RSEDU', '--confirm'],
    api: archApi([archChan(), archChan(), archChan({ is_archived: true })]),
  });
  is(exitOf(h), 0);
  is(archSeq(h), 'admin.conversations.search,admin.conversations.archive,admin.conversations.search,admin.conversations.search');
  is(h.sleeps.join(','), '10000');
  ok(/after 2 attempt/.test(h.text()));
});

test('channel-archive --confirm not-found (entry point): exit 1, named, no write', async () => {
  const h = await load({ runMain: true, argv: ['channel-archive', 'C0NOTHERE01', '--confirm'], api: archApi([null]) });
  is(exitOf(h), 1);
  ok(/refused: not-found/.test(errOf(h)), errOf(h));
  is(archWrites(h), 0);
});

test('channel-archive --confirm write error (entry point): API error surfaced, exit 1', async () => {
  const h = await load({
    runMain: true,
    argv: ['channel-archive', 'C04633RSEDU', '--confirm'],
    api: archApi([archChan()], { writeResult: { ok: false, error: 'restricted_action' } }),
  });
  is(exitOf(h), 1);
  ok(/admin.conversations.archive failed: restricted_action/.test(errOf(h)), errOf(h));
});

test('channel-archive --json (entry point) emits the result object', async () => {
  const h = await load({ runMain: true, argv: ['channel-archive', 'C04633RSEDU', '--json'], api: archApi([archChan()]) });
  const j = onlyJson(h);
  is(j.mode, 'dry-run');
  is(j.state.member_count, 6);
  is(j.decision.outcome, 'proceed');
});

test('channel-archive rejects a malformed --max-members before any API call', async () => {
  const h = await load({ runMain: true, argv: ['channel-archive', 'C04633RSEDU', '--max-members=lots', '--confirm'], api: archApi([archChan()]) });
  is(exitOf(h), 1);
  ok(/--max-members needs a non-negative integer/.test(errOf(h)), errOf(h));
  is(h.calls.length, 0);
});

test('channel-unarchive --confirm (entry point): re-check, unarchive, read-back', async () => {
  const h = await load({
    runMain: true,
    fakeTimers: true,
    argv: ['channel-unarchive', 'C0634KMGW2G', '--confirm'],
    api: archApi([archChan({ id: 'C0634KMGW2G', is_archived: true }), archChan({ id: 'C0634KMGW2G', is_archived: false })]),
  });
  is(errOf(h), '');
  is(archSeq(h), 'admin.conversations.search,admin.conversations.unarchive,admin.conversations.search');
  ok(/unarchived \(confirmed\)/.test(h.text()));
});

test('channel-unarchive dry run (entry point): no write; --max-members is refused as archive-only', async () => {
  const dry = await load({ runMain: true, argv: ['channel-unarchive', 'C0634KMGW2G'], api: archApi([archChan({ id: 'C0634KMGW2G', is_archived: true })]) });
  is(exitOf(dry), 0);
  is(archWrites(dry), 0);
  const bad = await load({ runMain: true, argv: ['channel-unarchive', 'C0634KMGW2G', '--max-members=1'], api: archApi([archChan()]) });
  is(exitOf(bad), 1);
  is(bad.calls.length, 0);
});

test('help lists channel-archive and channel-unarchive', async () => {
  const h = await load({ runMain: true, argv: ['--help'] });
  ok(/channel-archive <channel_id>/.test(h.text()));
  ok(/channel-unarchive <channel_id>/.test(h.text()));
});

// ── member_count -1 and Slack Connect channels through the real entry point ────

const connectArch = (over) =>
  archChan(
    Object.assign(
      {
        id: 'C03GXBSC72T',
        name: 'aem-pga-tour',
        is_private: false,
        member_count: 116,
        external_user_count: 41,
        is_ext_shared: true,
        conversation_host_id: 'E06V3987PMY',
        context_team_id: 'T0385CHDU9E',
        connected_team_ids: ['T0BQQL6FJ', 'E06V3987PMY', 'E08CP5WPXGT'],
        pending_connected_team_ids: [],
        internal_team_ids: ['T0385CHDU9E'],
      },
      over || {}
    )
  );

test('channel-archive --confirm (entry point): ACTIVE channel with member_count -1 refused members-unknown', async () => {
  const h = await load({
    runMain: true,
    argv: ['channel-archive', 'C04633RSEDU', '--confirm', '--max-members=50'],
    api: archApi([archChan({ is_archived: false, member_count: -1 })]),
  });
  is(exitOf(h), 1);
  ok(/refused: members-unknown/.test(errOf(h)), errOf(h));
  is(archWrites(h), 0);
});

test('channel-archive --confirm (entry point): ARCHIVED channel with -1 and --max-members is already-archived, exit 0', async () => {
  const h = await load({
    runMain: true,
    argv: ['channel-archive', 'C0634KMGW2G', '--confirm', '--max-members=2'],
    api: archApi([archChan({ id: 'C0634KMGW2G', is_archived: true, member_count: -1 })]),
  });
  is(errOf(h), '');
  is(exitOf(h), 0);
  ok(/already-archived: nothing to do/.test(h.text()));
  is(archWrites(h), 0);
});

test('channel-archive dry run (entry point) on a Slack Connect channel: refusal names the disconnect', async () => {
  const h = await load({ runMain: true, argv: ['channel-archive', 'C03GXBSC72T'], api: archApi([connectArch()]) });
  is(exitOf(h), 0);
  is(archWrites(h), 0);
  const t = h.text();
  ok(/--confirm would REFUSE: ext-shared-requires-allow-shared/.test(t), t);
  ok(/disconnect 41 external users from 2 external organisations/.test(t), t);
});

test('channel-archive dry run --allow-shared (entry point): plain-words warning, still no write', async () => {
  const h = await load({ runMain: true, argv: ['channel-archive', 'C03GXBSC72T', '--allow-shared'], api: archApi([connectArch()]) });
  is(exitOf(h), 0);
  is(archWrites(h), 0);
  const t = h.text();
  ok(/WARNING: Archiving will disconnect 41 external users from 2 external organisations \(T0BQQL6FJ, E08CP5WPXGT\)/.test(t), t);
  ok(/Unarchiving will NOT reconnect them/.test(t), t);
  ok(/--confirm would: re-read this channel/.test(t), t);
});

test('channel-archive --confirm without --allow-shared (entry point): Slack Connect channel refused, no write', async () => {
  const h = await load({ runMain: true, argv: ['channel-archive', 'C03GXBSC72T', '--confirm'], api: archApi([connectArch()]) });
  is(exitOf(h), 1);
  ok(/refused: ext-shared-requires-allow-shared/.test(errOf(h)), errOf(h));
  is(archWrites(h), 0);
});

test('channel-archive --confirm --allow-shared (entry point): archives, says what it disconnected, reads sharing back', async () => {
  // The measured post-archive row (5 of 5): flags false, 0 external users, no connected
  // teams. Whether conversation_host_id survives an archive was not measured; see the
  // next test for a row that keeps it.
  const after = connectArch({ is_archived: true, member_count: -1, is_ext_shared: false, external_user_count: 0, connected_team_ids: [], conversation_host_id: undefined });
  const h = await load({
    runMain: true,
    fakeTimers: true,
    argv: ['channel-archive', 'C03GXBSC72T', '--confirm', '--allow-shared'],
    api: archApi([connectArch(), after]),
  });
  is(errOf(h), '');
  is(archSeq(h), 'admin.conversations.search,admin.conversations.archive,admin.conversations.search');
  const t = h.text();
  ok(/--allow-shared given\. Archiving will disconnect 41 external users from 2 external organisations/.test(t), t);
  ok(/Unarchiving will NOT reconnect them/.test(t), t);
  ok(/Shared now:\s+not ext-shared/.test(t), t);
});

test('channel-unarchive dry run (entry point) warns that connections are not restored', async () => {
  const h = await load({ runMain: true, argv: ['channel-unarchive', 'C0634KMGW2G'], api: archApi([archChan({ id: 'C0634KMGW2G', is_archived: true })]) });
  is(exitOf(h), 0);
  ok(/unarchiving is not expected to reconnect them/.test(h.text()), h.text());
});

// ── An incomplete read is a failure, never "not found" (entry point) ──────────

test('channel-archive (entry point): ok:true body without conversations fails, not not-found', async () => {
  const h = await load({
    runMain: true,
    argv: ['channel-archive', 'C04633RSEDU', '--confirm'],
    api: (m) => (m === 'admin.conversations.search' ? { ok: true } : undefined),
  });
  is(exitOf(h), 1);
  ok(/channel lookup failed \(malformed_response\)/.test(errOf(h)), errOf(h));
  ok(!/not-found/.test(errOf(h)), 'must not be reported as not-found');
  is(archWrites(h), 0);
});

test('channel-archive (entry point): a missing response body fails, not not-found', async () => {
  const h = await load({
    runMain: true,
    argv: ['channel-archive', 'C04633RSEDU'],
    api: (m) => (m === 'admin.conversations.search' ? null : undefined),
  });
  is(exitOf(h), 1);
  ok(/channel lookup failed \(xhr_error\)/.test(errOf(h)), errOf(h));
  ok(!/not-found/.test(errOf(h)));
});

test('channel-archive --json (entry point): a failed lookup still emits a JSON result', async () => {
  const h = await load({
    runMain: true,
    argv: ['channel-archive', 'C04633RSEDU', '--json'],
    api: (m) => (m === 'admin.conversations.search' ? { ok: true } : undefined),
  });
  is(exitOf(h), 1);
  const j = onlyJson(h);
  is(j.status, 'read-error');
  is(j.state, null);
  is(j.decision, null);
});

test('channel-archive --json (entry point): a genuine not-found also emits JSON', async () => {
  const h = await load({ runMain: true, argv: ['channel-archive', 'C0NOTHERE01', '--json'], api: archApi([null]) });
  is(exitOf(h), 0);
  const j = onlyJson(h);
  is(j.decision.reason, 'not-found');
});

// ── P1: misspelled or invalid guard flags fail CLOSED, before any Slack call ──
//
// parseArgv keeps unknown flags. The command used to read only the correctly
// spelled keys, so --max-member=2 left the guard null and a confirmed archive
// went ahead without it. Every case below must exit non-zero with a named
// error and make ZERO Slack API calls, on the dry run and with --confirm.

const REFUSED_ARGS = [
  ['--max-member=2', ['--max-member=2'], /unknown-flag: --max-member \(did you mean --max-members\?\)/],
  ['--min-idle-day=180', ['--min-idle-day=180'], /unknown-flag: --min-idle-day \(did you mean --min-idle-days\?\)/],
  ['--allowshared', ['--allowshared'], /unknown-flag: --allowshared \(did you mean --allow-shared\?\)/],
  ['--max-members=abc', ['--max-members=abc'], /invalid-value: --max-members needs a non-negative integer, got "abc"/],
  ['--max-members= (empty)', ['--max-members='], /invalid-value: --max-members needs a non-negative integer, got ""/],
  ['--min-idle-days=-3', ['--min-idle-days=-3'], /invalid-value: --min-idle-days needs a non-negative integer, got "-3"/],
  ['--max-members (no value)', ['--max-members'], /invalid-value: --max-members needs a non-negative integer, got \(no value\)/],
  ['--max-members=2.5', ['--max-members=2.5'], /invalid-value: --max-members/],
  ['--confrm (typo of --confirm)', ['--confrm'], /unknown-flag: --confrm \(did you mean --confirm\?\)/],
  ['stray positional max-members=2', ['max-members=2'], /unexpected-argument: "max-members=2"/],
];

for (const [label, extra, want] of REFUSED_ARGS) {
  for (const confirm of [true, false]) {
    test('channel-archive ' + label + (confirm ? ' --confirm' : ' (dry run)') + ' (entry point): refused, no Slack call', async () => {
      const argv = ['channel-archive', 'C04633RSEDU'].concat(extra, confirm ? ['--confirm'] : []);
      const h = await load({ runMain: true, fakeTimers: true, argv, api: archApi([archChan(), archChan({ is_archived: true })]) });
      ok(exitOf(h) !== 0, 'must exit non-zero, got ' + exitOf(h));
      ok(want.test(errOf(h)), 'named error expected, got: ' + errOf(h));
      is(h.calls.length, 0, 'no Slack call may be made; calls were ' + archSeq(h));
    });
  }
}

test('channel-archive --allowshared before the id (entry point): refused, the id is not silently swallowed', async () => {
  // parseArgv hands the next word to an unknown flag as its value.
  const h = await load({ runMain: true, argv: ['channel-archive', '--allowshared', 'C04633RSEDU', '--confirm'], api: archApi([archChan()]) });
  ok(exitOf(h) !== 0);
  ok(/unknown-flag: --allowshared/.test(errOf(h)), errOf(h));
  is(h.calls.length, 0);
});

test('channel-unarchive --confrm / --max-members (entry point): refused, no Slack call', async () => {
  const a = await load({ runMain: true, argv: ['channel-unarchive', 'C0634KMGW2G', '--confrm'], api: archApi([archChan({ is_archived: true })]) });
  ok(/unknown-flag: --confrm \(did you mean --confirm\?\)/.test(errOf(a)), errOf(a));
  is(a.calls.length, 0);
  const b = await load({ runMain: true, argv: ['channel-unarchive', 'C0634KMGW2G', '--max-members=2', '--confirm'], api: archApi([archChan({ is_archived: true })]) });
  ok(/archive-only-flag: --max-members applies to channel-archive only/.test(errOf(b)), errOf(b));
  is(b.calls.length, 0);
});

test('channel-archive correctly spelled guards --confirm (entry point): CONTROL, proceeds to the write', async () => {
  // Proves the refusals above are not vacuous: the same argv shape with the
  // right spelling gets as far as the archive call.
  const h = await load({
    runMain: true,
    fakeTimers: true,
    argv: ['--ws=T0385CHDU9E', 'channel-archive', 'C04633RSEDU', '--max-members=10', '--min-idle-days=180', '--allow-shared', '--confirm', '--org=E06V3987PMY'],
    api: archApi([archChan(), archChan({ is_archived: true })]),
  });
  is(errOf(h), '');
  is(archSeq(h), 'admin.conversations.search,admin.conversations.archive,admin.conversations.search');
});

test('channel-archive correctly spelled guards, dry run (entry point): CONTROL, reads state', async () => {
  const h = await load({ runMain: true, argv: ['channel-archive', 'C04633RSEDU', '--max-members=10', '--min-idle-days=180'], api: archApi([archChan()]) });
  is(exitOf(h), 0);
  is(archSeq(h), 'admin.conversations.search');
});

// ── P2: --json writes exactly one JSON document to stdout, on every path ──────

const JSON_PATHS = [
  ['dry run, proceed', ['channel-archive', 'C04633RSEDU'], [archChan()], {}, 0, 'dry-run'],
  ['dry run, would refuse', ['channel-archive', 'C04633RSEDU', '--max-members=2'], [archChan()], {}, 0, 'dry-run'],
  ['confirm, refused', ['channel-archive', 'C04633RSEDU', '--confirm', '--max-members=2'], [archChan()], {}, 1, 'refused'],
  ['confirm, already-archived', ['channel-archive', 'C04633RSEDU', '--confirm'], [archChan({ is_archived: true, member_count: -1 })], {}, 0, 'already-archived'],
  ['confirm, success', ['channel-archive', 'C04633RSEDU', '--confirm'], [archChan(), archChan({ is_archived: true })], {}, 0, 'archived (confirmed)'],
  ['confirm, unconfirmed', ['channel-archive', 'C04633RSEDU', '--confirm'], [archChan()], {}, 3, 'archived (unconfirmed: search index did not reflect it after 10 attempts)'],
  ['confirm, write error', ['channel-archive', 'C04633RSEDU', '--confirm'], [archChan()], { writeResult: { ok: false, error: 'restricted_action' } }, 1, 'error'],
  ['dry run, not-found', ['channel-archive', 'C0NOTHERE01'], [null], {}, 0, 'dry-run'],
  ['unarchive, success', ['channel-unarchive', 'C04633RSEDU', '--confirm'], [archChan({ is_archived: true }), archChan()], {}, 0, 'unarchived (confirmed)'],
  ['unknown flag', ['channel-archive', 'C04633RSEDU', '--max-member=2', '--confirm'], [archChan()], {}, 1, 'unknown-flag'],
  ['invalid value', ['channel-archive', 'C04633RSEDU', '--min-idle-days=-3'], [archChan()], {}, 1, 'invalid-value'],
  ['missing channel id', ['channel-archive'], [archChan()], {}, 1, 'usage'],
  ['invalid channel id', ['channel-archive', 'not-an-id'], [archChan()], {}, 1, 'usage'],
  ['invalid --org', ['channel-archive', 'C04633RSEDU', '--org=bogus'], [archChan()], {}, 1, 'invalid-value'],
];

for (const [label, argv, states, extra, code, status] of JSON_PATHS) {
  test('--json ' + label + ' (entry point): entire stdout is one JSON document', async () => {
    const h = await load({ runMain: true, fakeTimers: true, argv: argv.concat(['--json']), api: archApi(states, extra) });
    is(exitOf(h), code, 'exit code; error was: ' + errOf(h));
    const j = onlyJson(h);
    is(j.status, status);
    is(j.exitCode, code);
    is(j.notice, 'xoxc session call: indistinguishable from a direct human action in channel event history.');
  });
}

test('--json read-error (entry point): entire stdout is one JSON document', async () => {
  const h = await load({ runMain: true, argv: ['channel-archive', 'C04633RSEDU', '--json'], api: (m) => (m === 'admin.conversations.search' ? { ok: true } : undefined) });
  is(exitOf(h), 1);
  const j = onlyJson(h);
  is(j.status, 'read-error');
  ok(/malformed_response/.test(j.error), j.error);
});

test('--json with no Slack tab (entry point): entire stdout is one JSON document', async () => {
  const h = await load({ runMain: true, noTab: true, argv: ['channel-archive', 'C04633RSEDU', '--json'], api: archApi([archChan()]) });
  is(exitOf(h), 1);
  const j = onlyJson(h);
  is(j.status, 'no-slack-tab');
  is(h.calls.length, 0);
});

test('--json unknown flag lists every error with its suggestion', async () => {
  const h = await load({ runMain: true, argv: ['channel-archive', 'C04633RSEDU', '--max-member=2', '--allowshared', '--json'], api: archApi([archChan()]) });
  const j = onlyJson(h);
  is(j.status, 'unknown-flag');
  is(j.errors.map((e) => e.flag + '>' + e.suggestion).join(','), '--max-member>--max-members,--allowshared>--allow-shared');
  is(h.calls.length, 0);
});

test('without --json the human output is unchanged (attribution notice still printed)', async () => {
  const h = await load({ runMain: true, argv: ['channel-archive', 'C04633RSEDU'], api: archApi([archChan()]) });
  ok(/indistinguishable from a direct human action/.test(h.text()));
  ok(/Dry run: channel-archive/.test(h.text()));
  ok(h.stdout.length > 5);
});

// MUTATION M6 (allow-list disabled): in checkChannelArchiveArgs, skip the
//   unknown-flag push. Caught by every "channel-archive --max-member=2 ...",
//   "--min-idle-day=180 ...", "--allowshared ...", "--confrm ..." test, and
//   "--json unknown flag ...".
// MUTATION M7 (a human line in JSON mode): print the attribution line with
//   console.log instead of say(). Caught by every "--json ... entire stdout is
//   one JSON document" test that reaches the flow.

// ── Codex round 2 (P2): a parseArgv failure in --json mode still emits JSON ────
//
// parseArgv throws at module init for a malformed boolean (e.g.
// --allow-shared=maybe), before any command runs. The top-level catch now emits
// the JSON error document for channel-archive / channel-unarchive.

const PARSE_FAILURES = [
  ['--allow-shared=maybe', ['channel-archive', 'C04633RSEDU', '--allow-shared=maybe', '--json'], 'archive'],
  ['--confirm=fasle', ['channel-archive', 'C04633RSEDU', '--confirm=fasle', '--json'], 'archive'],
  ['unarchive --confirm=maybe', ['channel-unarchive', 'C0634KMGW2G', '--confirm=maybe', '--json'], 'unarchive'],
  ['--json=true form', ['--ws=T0385CHDU9E', 'channel-archive', 'C04633RSEDU', '--allow-shared=2', '--json=true'], 'archive'],
];

for (const [label, argv, action] of PARSE_FAILURES) {
  test('--json parse failure ' + label + ' (entry point): entire stdout is one JSON document, no Slack call', async () => {
    const h = await load({ runMain: true, argv, api: archApi([archChan()]) });
    is(exitOf(h), 1);
    ok(/is not a valid boolean value/.test(errOf(h)), errOf(h));
    const j = onlyJson(h);
    is(j.status, 'invalid-value');
    is(j.action, action);
    ok(/^invalid-value: --[a-z-]+=\S+ is not a valid boolean value/.test(j.error), j.error);
    is(j.exitCode, 1);
    is(j.notice, 'xoxc session call: indistinguishable from a direct human action in channel event history.');
    is(h.calls.length, 0);
  });
}

test('parse failure WITHOUT --json (entry point): plain error only, nothing on stdout', async () => {
  const h = await load({ runMain: true, argv: ['channel-archive', 'C04633RSEDU', '--allow-shared=maybe'], api: archApi([archChan()]) });
  is(exitOf(h), 1);
  is(h.stdout.length, 0);
  is(h.calls.length, 0);
});

test('parse failure on another command with --json is unchanged (scope: archive commands only)', async () => {
  const h = await load({ runMain: true, argv: ['eg-status', 'U12345', '--confirm=fasle', '--json'] });
  is(exitOf(h), 1);
  is(h.stdout.length, 0);
});

// MUTATION M8 (parse failure answered in text only): in the top-level
//   parseArgv catch, drop the jsonInvocation / cli.out block. Caught by every
//   "--json parse failure ... entire stdout is one JSON document" test.

// ── Codex round 3 (P1): missing sharing flags fail closed (entry point) ────────

test('channel-archive --confirm --allow-shared (entry point): missing is_ext_shared refused sharing-unknown, no write', async () => {
  const h = await load({
    runMain: true,
    argv: ['channel-archive', 'C04633RSEDU', '--confirm', '--allow-shared'],
    api: archApi([archChan({ is_ext_shared: undefined })]),
  });
  is(exitOf(h), 1);
  ok(/refused: sharing-unknown/.test(errOf(h)), errOf(h));
  is(archWrites(h), 0);
});

test('channel-archive dry run --json (entry point): missing is_pending_ext_shared reported as would-refuse sharing-unknown', async () => {
  const h = await load({ runMain: true, argv: ['channel-archive', 'C04633RSEDU', '--json'], api: archApi([archChan({ is_pending_ext_shared: undefined })]) });
  is(exitOf(h), 0);
  const j = onlyJson(h);
  is(j.decision.reason, 'sharing-unknown');
  is(j.state.host, 'sharing-unknown');
  is(archWrites(h), 0);
});

test('channel-unarchive --confirm (entry point): missing sharing flags refused sharing-unknown, no write', async () => {
  const h = await load({
    runMain: true,
    argv: ['channel-unarchive', 'C0634KMGW2G', '--confirm'],
    api: archApi([archChan({ id: 'C0634KMGW2G', is_archived: true, is_ext_shared: 'false' })]),
  });
  is(exitOf(h), 1);
  ok(/refused: sharing-unknown/.test(errOf(h)), errOf(h));
  is(archWrites(h), 0);
});

// ── Codex round 4 (P2): malformed conversations.info fallback (entry point) ────

test('channel-archive --confirm (entry point): search miss + conversations.info {ok:true} fails, not not-found', async () => {
  const h = await load({
    runMain: true,
    argv: ['channel-archive', 'C04633RSEDU', '--confirm'],
    api: (m) =>
      m === 'admin.conversations.search'
        ? { ok: true, conversations: [], next_cursor: '' }
        : m === 'conversations.info'
          ? { ok: true }
          : undefined,
  });
  is(exitOf(h), 1);
  ok(/channel lookup failed \(conversations\.info: malformed_response\)/.test(errOf(h)), errOf(h));
  ok(!/not-found/.test(errOf(h)));
  is(archWrites(h), 0);
});

test('channel-archive --json (entry point): malformed conversations.info is a read-error JSON document', async () => {
  const h = await load({
    runMain: true,
    argv: ['channel-archive', 'C04633RSEDU', '--json'],
    api: (m) =>
      m === 'admin.conversations.search'
        ? { ok: true, conversations: [], next_cursor: '' }
        : m === 'conversations.info'
          ? { ok: true, channel: { id: 'C04633RSEDU' } }
          : undefined,
  });
  is(exitOf(h), 1);
  const j = onlyJson(h);
  is(j.status, 'read-error');
  ok(/conversations\.info: malformed_response/.test(j.error), j.error);
});

// ── Codex round 5 (P2): missing connected-team lists (entry point) ────────────

test('channel-archive dry run --allow-shared (entry point): missing connected_team_ids warns "unknown", never 0', async () => {
  const h = await load({ runMain: true, argv: ['channel-archive', 'C03GXBSC72T', '--allow-shared'], api: archApi([connectArch({ connected_team_ids: undefined })]) });
  is(exitOf(h), 0);
  const t = h.text();
  ok(/WARNING: Archiving will disconnect 41 external users from an unknown number of external organisations/.test(t), t);
  ok(/could not be determined/.test(t), t);
  ok(!/0 external organisations/.test(t), t);
  ok(/unknown number of external orgs/.test(t), 'the Shared: line says unknown too');
  is(archWrites(h), 0);
});

test('channel-archive --json dry run (entry point): impact.external_team_ids is null, not []', async () => {
  const h = await load({
    runMain: true,
    argv: ['channel-archive', 'C03GXBSC72T', '--allow-shared', '--json'],
    api: archApi([connectArch({ connected_team_ids: undefined, pending_connected_team_ids: 'bogus' })]),
  });
  const j = onlyJson(h);
  is(j.impact.external_team_ids, null);
  is(j.impact.pending_external_team_ids, null);
  is(j.state.external_team_ids, null);
});

// ── Codex round 6 (P2): a truthy non-boolean ok never authorizes an archive ────

test('channel-archive --confirm (entry point): search {ok:"false"} with a matching row fails closed, no archive call', async () => {
  const h = await load({
    runMain: true,
    argv: ['channel-archive', 'C04633RSEDU', '--confirm'],
    api: (m) => (m === 'admin.conversations.search' ? { ok: 'false', conversations: [archChan()], next_cursor: '' } : undefined),
  });
  is(exitOf(h), 1);
  ok(/channel lookup failed \(malformed_response\)/.test(errOf(h)), errOf(h));
  is(archWrites(h), 0);
});

test('channel-archive --json (entry point): search {ok:"false"} is a read-error JSON document', async () => {
  const h = await load({
    runMain: true,
    argv: ['channel-archive', 'C04633RSEDU', '--json'],
    api: (m) => (m === 'admin.conversations.search' ? { ok: 'false', conversations: [archChan()], next_cursor: '' } : undefined),
  });
  is(exitOf(h), 1);
  const j = onlyJson(h);
  is(j.status, 'read-error');
  is(j.state, null);
});

// ── Codex round 7 (P1): contradictory Slack Connect metadata (entry point) ────

test('channel-archive --confirm --allow-shared (entry point): flags false but external users + host id -> refused sharing-unknown, no write', async () => {
  const h = await load({
    runMain: true,
    argv: ['channel-archive', 'C04633RSEDU', '--confirm', '--allow-shared'],
    api: archApi([archChan({ is_ext_shared: false, is_pending_ext_shared: false, external_user_count: 3, conversation_host_id: 'E06V3987PMY' })]),
  });
  is(exitOf(h), 1);
  ok(/refused: sharing-unknown \(is_ext_shared and is_pending_ext_shared are false, but the same row reports external_user_count 3/.test(errOf(h)), errOf(h));
  is(archWrites(h), 0);
});

test('channel-archive dry run --json (entry point): contradictory row reports sharing-unknown with its evidence', async () => {
  const h = await load({
    runMain: true,
    argv: ['channel-archive', 'C04633RSEDU', '--json'],
    api: archApi([archChan({ connected_team_ids: ['T04650MFY', 'E06V3987PMY'] })]),
  });
  const j = onlyJson(h);
  is(j.decision.reason, 'sharing-unknown');
  is(j.state.sharing_conflicts.join('|'), 'external connected_team_ids T04650MFY');
});

test('channel-archive --confirm --allow-shared (entry point): a read-back row that keeps conversation_host_id prints Shared now UNKNOWN', async () => {
  const after = connectArch({ is_archived: true, member_count: -1, is_ext_shared: false, external_user_count: 0, connected_team_ids: [] });
  const h = await load({
    runMain: true,
    fakeTimers: true,
    argv: ['channel-archive', 'C03GXBSC72T', '--confirm', '--allow-shared'],
    api: archApi([connectArch(), after]),
  });
  is(errOf(h), '');
  ok(/archived \(confirmed\)/.test(h.text()));
  ok(/Shared now:\s+UNKNOWN \(flags say not shared, but the row reports conversation_host_id E06V3987PMY\)/.test(h.text()), h.text());
});

// ── channel-search (entry point): the 2026-10-02 invalid_arguments bug ───────
//
// The fake enforces Slack's measured contract: limit outside 2..20 answers
// invalid_arguments with response_metadata.messages naming the param.

function csApi(pages) {
  return (m, p) => {
    if (m !== 'admin.conversations.search') return undefined;
    const lim = Number(p.limit);
    if (!(lim >= 2 && lim <= 20)) {
      return {
        ok: false,
        error: 'invalid_arguments',
        response_metadata: { messages: ['[ERROR] must be less than 20 [json-pointer:/limit]'] },
      };
    }
    const idx = p.cursor ? Number(p.cursor.slice(1)) : 0;
    return {
      ok: true,
      conversations: pages[idx],
      total_count: pages.reduce((n, x) => n + x.length, 0),
      next_cursor: idx + 1 < pages.length ? 'p' + (idx + 1) : '',
    };
  };
}
const csRow = (id, name) => ({ id, name, member_count: 4, is_private: false, is_archived: false });
const CS_PAGES = [
  [csRow('C06MDH3G9J5', 'aem-santanderemea'), csRow('C09U7MX5R7X', 'aem-gruposantander'), csRow('C1', 'general')],
  [csRow('C0C5YLFVBDY', 'agents-santander'), csRow('C2', 'random')],
];
const csCalls = (h) => h.calls.filter((c) => c.method === 'admin.conversations.search');

// MUTATION TARGET: restore `limit: String(limit || 50)` and the default
// --limit of 50, and this fails exactly as the live command did.
test('channel-search --query=santander (entry point): succeeds, pages to the end, lists infix matches', async () => {
  const h = await load({ runMain: true, argv: ['channel-search', '--query=santander'], api: csApi(CS_PAGES) });
  is(errOf(h), '');
  is(exitOf(h), 0);
  const calls = csCalls(h);
  is(calls.length, 2);
  is(calls.map((c) => c.params.cursor).join('|'), '|p1');
  for (const c of calls) ok(Number(c.params.limit) <= 20, 'limit sent: ' + c.params.limit);
  const t = h.text();
  ok(/C0C5YLFVBDY\s+agents-santander/.test(t), t);
  ok(/C09U7MX5R7X\s+aem-gruposantander/.test(t), t);
  ok(!/\bgeneral\b/.test(t.split('Matching')[1] || ''), t);
  ok(/Matching:\s+3/.test(t), t);
});

test('channel-search --limit=50 --types=all (entry point): the page size is clamped to 20', async () => {
  const h = await load({ runMain: true, argv: ['channel-search', '--query=santander', '--limit=50', '--types=all'], api: csApi(CS_PAGES) });
  is(errOf(h), '');
  for (const c of csCalls(h)) {
    is(c.params.limit, '20');
    is(c.params.search_channel_types, 'all');
  }
  ok(/clamped from 50/.test(h.text()), h.text());
});

// MUTATION TARGET: drop slackErrorDetail from the die message.
test('channel-search (entry point): a Slack error prints response_metadata.messages', async () => {
  const h = await load({
    runMain: true,
    argv: ['channel-search', '--query=santander'],
    api: (m) =>
      m === 'admin.conversations.search'
        ? { ok: false, error: 'invalid_arguments', response_metadata: { messages: ['[ERROR] must be less than 20 [json-pointer:/limit]'] } }
        : undefined,
  });
  is(exitOf(h), 1);
  ok(/admin\.conversations\.search failed: invalid_arguments \(\[ERROR\] must be less than 20 \[json-pointer:\/limit\]\)/.test(errOf(h)), errOf(h));
});

test('channel-search --max=1 (entry point): max caps matches, not rows scanned', async () => {
  const h = await load({ runMain: true, argv: ['channel-search', '--query=santander', '--max=1'], api: csApi(CS_PAGES) });
  is(errOf(h), '');
  ok(/Matching:\s+1/.test(h.text()), h.text());
  ok(/--max reached/.test(h.text()), h.text());
});

test('channel-search --json with zero matches (entry point) still emits JSON', async () => {
  const h = await load({ runMain: true, argv: ['channel-search', '--query=nomatch-xyz', '--json'], api: csApi(CS_PAGES) });
  is(errOf(h), '');
  const j = JSON.parse(h.stdout[h.stdout.length - 1]);
  is(j.total, 0);
  is(j.complete, true);
  is(j.scanned, 5);
});

tile.json