Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/migrate-csv.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"seamless-cli": minor
---

Add `seamless migrate csv <file>` to import users from a CSV export into an instance. It is a dry run unless `--apply` is passed, finds columns by header (or a `--map` file kept beside the export), validates each row locally with the shared `@seamless-auth/types` schema, sends rows in batches of 200, and writes a CSV and JSON report of every row's outcome. Exits 1 when any row is rejected or invalid. Needs an auth server with `POST /admin/users/import`.
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,47 @@ the exception: it only exists after the code is sent, so that step genuinely nee

---

## Migrating users from another system

`seamless migrate csv` imports users from a CSV export (an HR system, a directory export, a
spreadsheet) into the instance you are signed in to with `seamless profile login`. It needs an admin
role and an auth server with `POST /admin/users/import`.

```bash
seamless migrate csv users.csv # dry run: nothing is written
seamless migrate csv users.csv --apply # import
```

It is a dry run unless you pass `--apply`, and it always writes a report next to the input
(`users.migrate-plan.csv` and `.json`, or `users.migrate-result.*` after `--apply`) with one line per
row: created, updated, unchanged, rejected (with the server's reason) or invalid. Exits 1 when any
row is rejected or invalid.

Columns are found by header: `email` (required), `externalId` (or `id`, `employee id`), `phone`,
`roles`, and `organizations` (or `department`). Cells holding several values are split on `;`.
Organizations are slugs or ids and must already exist. When the headers are different, keep a
mapping file beside the export so the import can be re-run the same way:

```json
{
"source": "hr-export",
"columns": { "email": "Work Email", "externalId": "Employee ID", "organizations": "Dept" },
"separator": "|",
"roles": ["staff"],
"organizationRoles": ["member"]
}
```

```bash
seamless migrate csv export.csv --map hr-mapping.json --apply
```

Re-running is safe. People are matched on `source` plus their external id, then on email, so a
second run reports them `unchanged`. Imports carry no passwords: each user signs in for the first
time by registering with the imported email, which proves they control it. Roles and memberships are
only ever added, an existing account's email is never changed, and admin roles are refused (grant
admin to individuals afterwards).

## What is configured for you

Seamless CLI handles the parts that are usually difficult to get right:
Expand Down
26 changes: 24 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"dependencies": {
"@clack/prompts": "^1.0.1",
"@napi-rs/keyring": "^1.3.0",
"@seamless-auth/types": "^0.24.0",
"adm-zip": "^0.5.16",
"kleur": "^4.1.5"
},
Expand Down
8 changes: 4 additions & 4 deletions resources/coverage-badge.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
37 changes: 37 additions & 0 deletions src/commands/helpTopics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,43 @@ users prepare-device-replacement <id> [--force] [--keep-sessions] [--keep-passke
},
],
},
{
name: "migrate",
usage: [
"seamless migrate csv <file> [--map <mapping.json>] [--source <name>] [--apply] [--report <path>] [--json]",
],
sections: [
{
heading: "migrate csv <file>",
body: `Import users from a CSV export into the instance (requires an admin role).
Runs as a dry run unless --apply is passed, and writes a report either way.

Columns are found by header name: email (required), externalId (or id,
employee id), phone, roles, and organizations (or department). Multi-value
cells are split on ";". Organizations are slugs or ids, and must exist.

--map <mapping.json>
• Name the columns and defaults yourself, for example:
{ "source": "hr-export",
"columns": { "email": "Work Email", "externalId": "Employee ID" },
"separator": "|", "roles": ["staff"], "organizationRoles": ["member"] }
--source <name>
• The system the users come from (default csv). Re-runs with the same
source match people on their externalId, so keep it stable
--apply
• Write to the instance. Without it nothing is written
--report <path>
• Where to write <path>.csv and <path>.json (default: next to the input)
--json
• Print the report as JSON

Imports carry no passwords. Each user signs in for the first time by
registering with their email. Roles and memberships are only added, never
removed, and admin roles are refused. Exits 1 when any row is rejected or
invalid.`,
},
],
},
{
name: "org",
usage: [
Expand Down
161 changes: 161 additions & 0 deletions src/commands/migrate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import fs from "fs";
import os from "os";
import path from "path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createAuthClient, type AuthClient } from "../core/authClient.js";
import { runMigrate } from "./migrate.js";

vi.mock("../core/authClient.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../core/authClient.js")>();
return { ...actual, createAuthClient: vi.fn() };
});

class ExitError extends Error {
constructor(readonly code: number) {
super(`process.exit(${code})`);
}
}

const request = vi.fn();
const fakeClient = {
profile: { name: "default", instanceUrl: "https://auth.example.com" },
get: vi.fn(),
post: vi.fn(),
request,
} as unknown as AuthClient;

let dir: string;
let logSpy: ReturnType<typeof vi.spyOn>;

function writeCsv(lines: string[]) {
const file = path.join(dir, "users.csv");
fs.writeFileSync(file, lines.join("\n"));
return file;
}

function respond(results: unknown[], dryRun: boolean) {
request.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers(),
data: {
source: "csv",
dryRun,
summary: { created: 0, updated: 0, unchanged: 0, rejected: 0 },
results,
},
});
}

const output = () => logSpy.mock.calls.map((c) => String(c[0])).join("\n");

beforeEach(() => {
vi.clearAllMocks();
dir = fs.mkdtempSync(path.join(os.tmpdir(), "seamless-migrate-cmd-"));
vi.mocked(createAuthClient).mockResolvedValue(fakeClient);
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new ExitError(code ?? 0);
}) as never);
logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
vi.spyOn(console, "error").mockImplementation(() => undefined);
});

afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});

describe("seamless migrate csv", () => {
it("is a dry run by default and writes a plan report beside the input", async () => {
const file = writeCsv(["email", "ada@example.com"]);
respond([{ index: 0, email: "ada@example.com", status: "created", changes: ["created"] }], true);

await runMigrate(["csv", file]);

const body = JSON.parse(request.mock.calls[0][1].body);
expect(body).toEqual({ source: "csv", dryRun: true, users: [{ email: "ada@example.com" }] });
expect(fs.existsSync(path.join(dir, "users.migrate-plan.csv"))).toBe(true);
expect(fs.existsSync(path.join(dir, "users.migrate-plan.json"))).toBe(true);
expect(output()).toMatch(/Dry run: nothing was written/);
});

it("writes with --apply and names the report as a result", async () => {
const file = writeCsv(["email", "ada@example.com"]);
respond([{ index: 0, email: "ada@example.com", status: "created", userId: "u1" }], false);

await runMigrate(["csv", file, "--apply", "--source", "hr-export"]);

expect(JSON.parse(request.mock.calls[0][1].body)).toMatchObject({
source: "hr-export",
dryRun: false,
});
expect(fs.existsSync(path.join(dir, "users.migrate-result.json"))).toBe(true);
});

it("exits 1 when a row is rejected or invalid, after writing the report", async () => {
const file = writeCsv(["email,roles", "ada@example.com,admin", "nope,"]);
respond(
[{ index: 0, email: "ada@example.com", status: "rejected", reason: "admin_role_not_allowed" }],
true,
);

await expect(runMigrate(["csv", file, "--report", path.join(dir, "out")])).rejects.toThrow(
"process.exit(1)",
);

const report = JSON.parse(fs.readFileSync(path.join(dir, "out.json"), "utf-8"));
expect(report.summary).toMatchObject({ rejected: 1, invalid: 1 });
expect(output()).toMatch(/row 2.*admin_role_not_allowed/s);
});

it("writes what finished when a later batch fails", async () => {
const lines = ["email", ...Array.from({ length: 201 }, (_, i) => `u${i}@example.com`)];
const file = writeCsv(lines);
respond(
Array.from({ length: 200 }, (_, index) => ({
index,
email: `u${index}@example.com`,
status: "created",
})),
false,
);
request.mockResolvedValueOnce({ ok: false, status: 500, data: null, headers: new Headers() });

await expect(runMigrate(["csv", file, "--apply"])).rejects.toThrow("process.exit(1)");

const report = JSON.parse(
fs.readFileSync(path.join(dir, "users.migrate-result.json"), "utf-8"),
);
expect(report.rows).toHaveLength(200);
});

it("does not contact the instance when every row is invalid", async () => {
const file = writeCsv(["email", "nope"]);

await expect(runMigrate(["csv", file])).rejects.toThrow("process.exit(1)");

expect(createAuthClient).not.toHaveBeenCalled();
});

it("prints JSON with --json", async () => {
const file = writeCsv(["email", "ada@example.com"]);
respond([{ index: 0, email: "ada@example.com", status: "unchanged" }], true);

await runMigrate(["csv", file, "--json"]);

const printed = JSON.parse(String(logSpy.mock.calls.at(-1)![0]));
expect(printed.summary).toMatchObject({ unchanged: 1 });
});

it("rejects an unknown source and a missing file argument", async () => {
await expect(runMigrate(["okta"])).rejects.toThrow("process.exit(1)");
await expect(runMigrate(["csv"])).rejects.toThrow("process.exit(1)");
});

it("explains a CSV without an email column", async () => {
const file = writeCsv(["name", "Ada"]);

await expect(runMigrate(["csv", file])).rejects.toThrow("process.exit(1)");

expect(vi.mocked(console.error).mock.calls[0][0]).toMatch(/No email column/);
});
});
Loading
Loading