diff --git a/CODEBASE.md b/CODEBASE.md index 3f44dc0..2f6cfcb 100644 --- a/CODEBASE.md +++ b/CODEBASE.md @@ -51,7 +51,7 @@ src/ │ ├─ inbox.ts, mentions.ts, search.ts, react.ts, view.ts, │ │ user.ts, workspace.ts, doctor.ts, changelog.ts │ ├─ thread/, conversation/, msg/, comment/, channel/, groups/, -│ │ account/, auth/, config/, skill/, completion/, update/ +│ │ account/, auth/, config/, skill/, completion/, update/, migrate/ │ └─ /index.ts # registerXxxCommand(program) + sibling files per subcommand ├─ lib/ # Shared utilities. See catalog — don't reimplement. │ ├─ skills/ # content.ts (SKILL_CONTENT) + installer plumbing @@ -113,6 +113,8 @@ don't duplicate it here. - **Identity & infra** — `user`/`users`, `workspace`/`workspaces`, `auth` (login/logout/token/status), `account` (list/current/use/remove), `config`, `skill`, `completion`, `update`, `changelog`, `doctor` +- **Migration** (`migrate/`) — `urls` (translate twist.com URLs to Comms; uses a + Twist token via `--twist-token` / `TWIST_AUTH_TOKEN`) ## `src/lib/` catalog — don't reimplement diff --git a/package-lock.json b/package-lock.json index 41b5d09..b68e9cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "MIT", "dependencies": { "@doist/cli-core": "0.26.0", - "@doist/comms-sdk": "0.5.0", + "@doist/comms-sdk": "0.7.0", "@pnpm/tabtab": "0.5.4", "chalk": "5.6.2", "commander": "14.0.3", @@ -182,34 +182,37 @@ } }, "node_modules/@doist/comms-sdk": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@doist/comms-sdk/-/comms-sdk-0.5.0.tgz", - "integrity": "sha512-voccKFXUKwdvquMduJygsL0tiidoZtOSnmIWhzeAgd0omQYHR3hMzbuKqZI3Y9DSsaARGNKz8LUAixYrZyaF8A==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@doist/comms-sdk/-/comms-sdk-0.7.0.tgz", + "integrity": "sha512-dOxP1WNYzK1hZ0XSLsSU2AIZkDOgytOKL+WrV9zng9N2Q2N3zhrLeKHF1jtlKTJ89Zc1yc+zgiuijVz3ro8Oeg==", "license": "MIT", "dependencies": { - "camelcase": "8.0.0", - "ts-custom-error": "^3.2.0", - "undici": "7.24.8", - "uuid": "^11.1.0", - "zod": "4.1.12" + "camelcase": "9.0.0", + "ts-custom-error": "3.3.1", + "undici": "7.28.0", + "uuid": "11.1.1", + "zod": "4.4.3" }, - "peerDependencies": { - "type-fest": "^4.12.0 || ^5.1.0" + "engines": { + "node": ">=20.18.1" } }, - "node_modules/@doist/comms-sdk/node_modules/undici": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", - "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", + "node_modules/@doist/comms-sdk/node_modules/camelcase": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-9.0.0.tgz", + "integrity": "sha512-TO9xmyXTZ9HUHI8M1OnvExxYB0eYVS/1e5s7IDMTAoIcwUd+aNcFODs6Xk83mobk0velyHFQgA1yIrvYc6wclw==", "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@doist/comms-sdk/node_modules/zod": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", - "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -219,6 +222,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -230,6 +234,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -240,6 +245,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -907,6 +913,7 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1802,6 +1809,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1818,6 +1826,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1834,6 +1843,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1850,6 +1860,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1866,6 +1877,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1882,6 +1894,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1898,6 +1911,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1914,6 +1928,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1930,6 +1945,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1946,6 +1962,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1962,6 +1979,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1978,6 +1996,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1994,6 +2013,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2012,6 +2032,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2028,6 +2049,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2630,6 +2652,7 @@ "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2671,7 +2694,7 @@ "version": "25.8.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -4287,6 +4310,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5183,6 +5207,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5203,6 +5228,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5223,6 +5249,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5243,6 +5270,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5263,6 +5291,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5283,6 +5312,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5303,6 +5333,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5323,6 +5354,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5343,6 +5375,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5363,6 +5396,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5383,6 +5417,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9666,6 +9701,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD", "optional": true }, @@ -9732,10 +9768,9 @@ } }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", - "dev": true, + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -9745,7 +9780,7 @@ "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { diff --git a/package.json b/package.json index ba547b3..f8c387c 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ ], "dependencies": { "@doist/cli-core": "0.26.0", - "@doist/comms-sdk": "0.5.0", + "@doist/comms-sdk": "0.7.0", "@pnpm/tabtab": "0.5.4", "chalk": "5.6.2", "commander": "14.0.3", diff --git a/skills/comms-cli/SKILL.md b/skills/comms-cli/SKILL.md index f28b472..4d6d3c1 100644 --- a/skills/comms-cli/SKILL.md +++ b/skills/comms-cli/SKILL.md @@ -43,6 +43,7 @@ tdc config set # Set a user preference (e.g. unarchive-new-th tdc doctor # Diagnose CLI setup and environment issues tdc update # Update CLI to latest version tdc changelog # Show recent changelog entries +tdc migrate urls # Translate old twist.com URLs to Comms URLs (needs a Twist token) ``` OAuth login uses Todoist OAuth for Comms access. The default grant can read Comms data and create/update content or messages. It does not include delete, channel management, or user/workspace write scopes; use `tdc auth login --full-access` only when needed. Stored auth uses the system credential manager when available. If secure storage is unavailable, `tdc` warns and falls back to `~/.config/comms-cli/config.json`. `COMMS_API_TOKEN` always takes priority over the stored token. @@ -353,6 +354,26 @@ tdc changelog -n 3 # Show last 3 versions tdc changelog --count 10 # Show last 10 versions ``` +## Migration + +Translate old `twist.com` URLs to their Comms equivalents (e.g. for rewriting bookmarks or history). The migration endpoint authenticates with a **Twist** token (not a Comms one). Prefer the `TWIST_AUTH_TOKEN` environment variable — a CLI flag exposes the token in process listings. `--twist-token` is supported for ad-hoc use and overrides the env var. If the Twist CLI (`tw`) is installed, populate the env var inline with `TWIST_AUTH_TOKEN="$(tw auth token view)"`. + +```bash +# Comma-separated list as an argument (token from the environment) +TWIST_AUTH_TOKEN="$(tw auth token view)" tdc migrate urls "https://twist.com/a/1/ch/2/t/3,https://twist.com/a/1/ch/2/t/4" + +# Or pipe URLs via stdin (comma- or newline-separated) +cat old-urls.txt | TWIST_AUTH_TOKEN="$(tw auth token view)" tdc migrate urls + +# --twist-token overrides the env var (visible in process lists — prefer the env var) +tdc migrate urls "https://twist.com/a/1/ch/2/t/3" --twist-token + +tdc migrate urls "" --json # Structured output ({ oldUrl, newUrl } / { oldUrl, error }) +tdc migrate urls "" --ndjson # Newline-delimited JSON +``` + +Output is one line per URL in input order: `old -> new` on success, `old ✗ ` on failure (`invalid_url` / `not_imported`, or `API_ERROR` when the endpoint gives no code). Per-URL failures don't abort the run; the command exits non-zero if any URL fails to migrate. + ## Global Options ```bash diff --git a/src/commands/migrate/index.ts b/src/commands/migrate/index.ts new file mode 100644 index 0000000..af0588d --- /dev/null +++ b/src/commands/migrate/index.ts @@ -0,0 +1,26 @@ +import type { Command } from 'commander' +import { migrateUrls } from './urls.js' + +export function registerMigrateCommand(program: Command): void { + const migrate = program.command('migrate').description('Twist→Comms migration helpers') + + migrate + .command('urls [urls]') + .description('Translate twist.com URLs to their Comms equivalents') + .option('--twist-token ', 'Twist auth token (overrides $TWIST_AUTH_TOKEN)') + .option('--json', 'Output as JSON') + .option('--ndjson', 'Output as newline-delimited JSON') + .addHelpText( + 'after', + ` +The migration endpoint needs a Twist (not Comms) token. Prefer the TWIST_AUTH_TOKEN +environment variable — a CLI flag exposes the token in process listings. If the +Twist CLI (tw) is installed, you can populate it inline: + +Examples: + TWIST_AUTH_TOKEN="$(tw auth token view)" tdc migrate urls "https://twist.com/a/1/ch/2/t/3,https://twist.com/a/1/ch/2/t/4" + cat old-urls.txt | TWIST_AUTH_TOKEN="$(tw auth token view)" tdc migrate urls + tdc migrate urls "https://twist.com/a/1/ch/2/t/3" --json # token from $TWIST_AUTH_TOKEN`, + ) + .action(migrateUrls) +} diff --git a/src/commands/migrate/migrate.test.ts b/src/commands/migrate/migrate.test.ts new file mode 100644 index 0000000..13a230d --- /dev/null +++ b/src/commands/migrate/migrate.test.ts @@ -0,0 +1,262 @@ +import { captureConsole, createTestProgram } from '@doist/cli-core/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const sdkMocks = vi.hoisted(() => ({ + fetchNewCommsUrls: vi.fn(), +})) + +vi.mock('@doist/comms-sdk', () => ({ + fetchNewCommsUrls: sdkMocks.fetchNewCommsUrls, +})) + +const inputMocks = vi.hoisted(() => ({ + readStdin: vi.fn().mockResolvedValue(null), +})) + +vi.mock('../../lib/input.js', () => ({ + readStdin: inputMocks.readStdin, +})) + +vi.mock('chalk') + +import { readStdin } from '../../lib/input.js' +import { registerMigrateCommand } from './index.js' + +const createProgram = () => createTestProgram(registerMigrateCommand) + +const OLD_URL = 'https://twist.com/a/1/ch/2/t/3' +const NEW_URL = 'https://comms.todoist.com/a/1/ch/2/t/3' + +function success(oldUrl: string, newUrl: string) { + return { oldUrl, newUrl } +} + +function failure(oldUrl: string, code: string, message = 'Request failed') { + return { oldUrl, error: { message, responseData: { error: { code } } } } +} + +describe('migrate urls', () => { + const originalToken = process.env.TWIST_AUTH_TOKEN + const originalBaseUrl = process.env.TWIST_BASE_URL + + beforeEach(() => { + vi.clearAllMocks() + inputMocks.readStdin.mockResolvedValue(null) + delete process.env.TWIST_AUTH_TOKEN + delete process.env.TWIST_BASE_URL + process.exitCode = undefined + }) + + afterEach(() => { + if (originalToken === undefined) delete process.env.TWIST_AUTH_TOKEN + else process.env.TWIST_AUTH_TOKEN = originalToken + if (originalBaseUrl === undefined) delete process.env.TWIST_BASE_URL + else process.env.TWIST_BASE_URL = originalBaseUrl + process.exitCode = undefined + }) + + it('parses a comma-separated argument and passes URLs + flag token to the SDK', async () => { + sdkMocks.fetchNewCommsUrls.mockResolvedValue([ + success(OLD_URL, NEW_URL), + success('https://twist.com/a/1/ch/2/t/4', 'https://comms.todoist.com/a/1/ch/2/t/4'), + ]) + const consoleSpy = captureConsole('log') + + await createProgram().parseAsync([ + 'node', + 'tdc', + 'migrate', + 'urls', + `${OLD_URL},https://twist.com/a/1/ch/2/t/4`, + '--twist-token', + 'flag-token', + ]) + + expect(sdkMocks.fetchNewCommsUrls).toHaveBeenCalledWith( + { + oldUrls: [OLD_URL, 'https://twist.com/a/1/ch/2/t/4'], + twistToken: 'flag-token', + }, + undefined, + ) + expect(consoleSpy).toHaveBeenCalledWith(`${OLD_URL} -> ${NEW_URL}`) + }) + + it('reads URLs from stdin when no argument is given', async () => { + inputMocks.readStdin.mockResolvedValue(`${OLD_URL}\nhttps://twist.com/a/1/ch/2/t/4\n`) + sdkMocks.fetchNewCommsUrls.mockResolvedValue([success(OLD_URL, NEW_URL)]) + captureConsole('log') + + await createProgram().parseAsync([ + 'node', + 'tdc', + 'migrate', + 'urls', + '--twist-token', + 'flag-token', + ]) + + expect(readStdin).toHaveBeenCalled() + expect(sdkMocks.fetchNewCommsUrls).toHaveBeenCalledWith( + expect.objectContaining({ + oldUrls: [OLD_URL, 'https://twist.com/a/1/ch/2/t/4'], + }), + undefined, + ) + }) + + it('falls back to TWIST_AUTH_TOKEN when no flag is provided', async () => { + process.env.TWIST_AUTH_TOKEN = 'env-token' + sdkMocks.fetchNewCommsUrls.mockResolvedValue([success(OLD_URL, NEW_URL)]) + captureConsole('log') + + await createProgram().parseAsync(['node', 'tdc', 'migrate', 'urls', OLD_URL]) + + expect(sdkMocks.fetchNewCommsUrls).toHaveBeenCalledWith( + expect.objectContaining({ twistToken: 'env-token' }), + undefined, + ) + }) + + it('errors with NO_TOKEN when neither flag nor env var is set', async () => { + await expect( + createProgram().parseAsync(['node', 'tdc', 'migrate', 'urls', OLD_URL]), + ).rejects.toHaveProperty('code', 'NO_TOKEN') + expect(sdkMocks.fetchNewCommsUrls).not.toHaveBeenCalled() + }) + + it('errors with MISSING_CONTENT when no URLs are provided', async () => { + await expect( + createProgram().parseAsync([ + 'node', + 'tdc', + 'migrate', + 'urls', + '--twist-token', + 'flag-token', + ]), + ).rejects.toHaveProperty('code', 'MISSING_CONTENT') + expect(sdkMocks.fetchNewCommsUrls).not.toHaveBeenCalled() + }) + + it('emits structured JSON with --json', async () => { + sdkMocks.fetchNewCommsUrls.mockResolvedValue([ + success(OLD_URL, NEW_URL), + failure('https://twist.com/bad', 'invalid_url'), + ]) + const consoleSpy = captureConsole('log') + + await createProgram().parseAsync([ + 'node', + 'tdc', + 'migrate', + 'urls', + `${OLD_URL},https://twist.com/bad`, + '--twist-token', + 'flag-token', + '--json', + ]) + + const parsed = JSON.parse(consoleSpy.mock.calls[0][0] as string) + expect(parsed).toEqual([ + { oldUrl: OLD_URL, newUrl: NEW_URL }, + { + oldUrl: 'https://twist.com/bad', + error: { code: 'invalid_url', message: 'Request failed' }, + }, + ]) + }) + + it('prints a ✗ line and sets a non-zero exit code on partial failure', async () => { + sdkMocks.fetchNewCommsUrls.mockResolvedValue([ + success(OLD_URL, NEW_URL), + failure('https://twist.com/bad', 'not_imported'), + ]) + const consoleSpy = captureConsole('log') + + await createProgram().parseAsync([ + 'node', + 'tdc', + 'migrate', + 'urls', + `${OLD_URL},https://twist.com/bad`, + '--twist-token', + 'flag-token', + ]) + + expect(consoleSpy).toHaveBeenCalledWith(`${OLD_URL} -> ${NEW_URL}`) + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('https://twist.com/bad')) + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('✗ not_imported')) + expect(process.exitCode).toBe(1) + }) + + it('emits newline-delimited JSON with --ndjson', async () => { + sdkMocks.fetchNewCommsUrls.mockResolvedValue([ + success(OLD_URL, NEW_URL), + failure('https://twist.com/bad', 'invalid_url'), + ]) + const consoleSpy = captureConsole('log') + + await createProgram().parseAsync([ + 'node', + 'tdc', + 'migrate', + 'urls', + `${OLD_URL},https://twist.com/bad`, + '--twist-token', + 'flag-token', + '--ndjson', + ]) + + const lines = (consoleSpy.mock.calls[0][0] as string).split('\n').filter(Boolean) + expect(lines.map((line) => JSON.parse(line))).toEqual([ + { oldUrl: OLD_URL, newUrl: NEW_URL }, + { + oldUrl: 'https://twist.com/bad', + error: { code: 'invalid_url', message: 'Request failed' }, + }, + ]) + }) + + it('falls back to a stable API_ERROR code when the failure carries no code', async () => { + sdkMocks.fetchNewCommsUrls.mockResolvedValue([ + { oldUrl: OLD_URL, error: { message: 'boom', responseData: undefined } }, + ]) + const consoleSpy = captureConsole('log') + + await createProgram().parseAsync([ + 'node', + 'tdc', + 'migrate', + 'urls', + OLD_URL, + '--twist-token', + 'flag-token', + '--json', + ]) + + const parsed = JSON.parse(consoleSpy.mock.calls[0][0] as string) + expect(parsed).toEqual([{ oldUrl: OLD_URL, error: { code: 'API_ERROR', message: 'boom' } }]) + expect(process.exitCode).toBe(1) + }) + + it('forwards TWIST_BASE_URL to the SDK as { baseUrl }', async () => { + process.env.TWIST_BASE_URL = 'https://staging.twist.com' + sdkMocks.fetchNewCommsUrls.mockResolvedValue([success(OLD_URL, NEW_URL)]) + captureConsole('log') + + await createProgram().parseAsync([ + 'node', + 'tdc', + 'migrate', + 'urls', + OLD_URL, + '--twist-token', + 'flag-token', + ]) + + expect(sdkMocks.fetchNewCommsUrls).toHaveBeenCalledWith(expect.any(Object), { + baseUrl: 'https://staging.twist.com', + }) + }) +}) diff --git a/src/commands/migrate/urls.ts b/src/commands/migrate/urls.ts new file mode 100644 index 0000000..222ccf6 --- /dev/null +++ b/src/commands/migrate/urls.ts @@ -0,0 +1,109 @@ +import { type CommsRequestError, fetchNewCommsUrls } from '@doist/comms-sdk' +import { CliError } from '../../lib/errors.js' +import { readStdin } from '../../lib/input.js' +import { colors, formatJson, formatNdjson } from '../../lib/output.js' +import { withSpinner } from '../../lib/spinner.js' + +type MigrateUrlsOptions = { + twistToken?: string + json?: boolean + ndjson?: boolean +} + +/** Serialisable shape of a single URL migration result for JSON/NDJSON output. */ +type MigrateUrlsResult = { + oldUrl: string + newUrl?: string + error?: { code: string; message: string } +} + +/** + * Pulls the machine-readable error code out of a {@link CommsRequestError}. + * + * Not-migratable URLs come back as `{ error: { code: 'invalid_url' } }` (400) or + * `{ error: { code: 'not_imported' } }` (404) in `responseData`. Falls back to a + * stable `API_ERROR` code (the human-readable text stays in `message`) so callers + * never have to branch on free-form prose. + */ +function extractErrorCode(error: CommsRequestError): string { + const data = error.responseData + if (data && typeof data === 'object' && 'error' in data) { + const inner = (data as { error?: unknown }).error + if (inner && typeof inner === 'object' && 'code' in inner) { + const code = (inner as { code?: unknown }).code + if (typeof code === 'string') return code + } + } + return 'API_ERROR' +} + +/** Splits a comma- or whitespace-separated blob into trimmed, non-empty URLs. */ +function parseUrls(input: string): string[] { + return input + .split(/[\s,]+/) + .map((url) => url.trim()) + .filter(Boolean) +} + +export async function migrateUrls( + urlsArg: string | undefined, + options: MigrateUrlsOptions, +): Promise { + // Flag wins over the environment variable, but the env var is the recommended + // primary method (a CLI flag exposes the token in process listings). + const twistToken = options.twistToken ?? process.env.TWIST_AUTH_TOKEN + if (!twistToken) { + throw new CliError('NO_TOKEN', 'No Twist token provided.', [ + 'Set TWIST_AUTH_TOKEN (e.g. TWIST_AUTH_TOKEN="$(tw auth token view)" — requires the Twist CLI), or', + 'pass --twist-token .', + ]) + } + + // readStdin() waits briefly for the first byte then reads to end, so an open + // but empty stdin (CI, spawned children) raises MISSING_CONTENT instead of hanging. + const rawInput = urlsArg ?? (await readStdin()) + const oldUrls = rawInput ? parseUrls(rawInput) : [] + if (oldUrls.length === 0) { + throw new CliError('MISSING_CONTENT', 'No URLs provided.', [ + 'Pass a comma-separated list as an argument, or pipe URLs via stdin.', + ]) + } + + // The migration endpoint lives on Twist; honour TWIST_BASE_URL for staging/tests, + // mirroring the COMMS_BASE_URL convention used for the Comms API. + const baseUrl = process.env.TWIST_BASE_URL + const results = await withSpinner({ text: 'Migrating URLs...' }, () => + fetchNewCommsUrls({ oldUrls, twistToken }, baseUrl ? { baseUrl } : undefined), + ) + + if (options.json || options.ndjson) { + const output: MigrateUrlsResult[] = results.map((result) => + result.error + ? { + oldUrl: result.oldUrl, + error: { + code: extractErrorCode(result.error), + message: result.error.message, + }, + } + : { oldUrl: result.oldUrl, newUrl: result.newUrl }, + ) + console.log(options.json ? formatJson(output) : formatNdjson(output)) + } else { + // Iterate results directly for text output — no need to materialise the + // simplified shape just to print and tally. + for (const result of results) { + if (result.error) { + const label = colors.error(`✗ ${extractErrorCode(result.error)}`) + console.log(`${result.oldUrl} ${label}`) + } else { + console.log(`${result.oldUrl} -> ${result.newUrl}`) + } + } + } + + // Surface partial failure to scripts/CI: exit non-zero if any URL failed. + if (results.some((result) => result.error)) { + process.exitCode = 1 + } +} diff --git a/src/index.ts b/src/index.ts index f62c6e3..398bc0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,8 @@ const loadConfigCommand = async () => (await import('./commands/config/index.js')).registerConfigCommand const loadAccountCommand = async () => (await import('./commands/account/index.js')).registerAccountCommand +const loadMigrateCommand = async () => + (await import('./commands/migrate/index.js')).registerMigrateCommand const commands: Record Promise<(p: Command) => void>]> = { workspaces: ['List all workspaces', loadWorkspaceCommand], @@ -72,6 +74,7 @@ const commands: Record Promise<(p: Command) => void>]> = loadGroupsCommand, ], config: ['Manage CLI configuration', loadConfigCommand], + migrate: ['Twist→Comms migration helpers (urls)', loadMigrateCommand], } const commandAliases: Record = { @@ -185,6 +188,7 @@ if (process.argv[2] === 'completion-server') { 'account', 'config', 'skill', + 'migrate', ]) const wantsRaw = process.argv.slice(2).includes('--raw') const needsMarkdown = diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index 962d1d4..89741e4 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -47,6 +47,7 @@ tdc config set # Set a user preference (e.g. unarchive-new-th tdc doctor # Diagnose CLI setup and environment issues tdc update # Update CLI to latest version tdc changelog # Show recent changelog entries +tdc migrate urls # Translate old twist.com URLs to Comms URLs (needs a Twist token) \`\`\` OAuth login uses Todoist OAuth for Comms access. The default grant can read Comms data and create/update content or messages. It does not include delete, channel management, or user/workspace write scopes; use \`tdc auth login --full-access\` only when needed. Stored auth uses the system credential manager when available. If secure storage is unavailable, \`tdc\` warns and falls back to \`~/.config/comms-cli/config.json\`. \`COMMS_API_TOKEN\` always takes priority over the stored token. @@ -357,6 +358,26 @@ tdc changelog -n 3 # Show last 3 versions tdc changelog --count 10 # Show last 10 versions \`\`\` +## Migration + +Translate old \`twist.com\` URLs to their Comms equivalents (e.g. for rewriting bookmarks or history). The migration endpoint authenticates with a **Twist** token (not a Comms one). Prefer the \`TWIST_AUTH_TOKEN\` environment variable — a CLI flag exposes the token in process listings. \`--twist-token\` is supported for ad-hoc use and overrides the env var. If the Twist CLI (\`tw\`) is installed, populate the env var inline with \`TWIST_AUTH_TOKEN="$(tw auth token view)"\`. + +\`\`\`bash +# Comma-separated list as an argument (token from the environment) +TWIST_AUTH_TOKEN="$(tw auth token view)" tdc migrate urls "https://twist.com/a/1/ch/2/t/3,https://twist.com/a/1/ch/2/t/4" + +# Or pipe URLs via stdin (comma- or newline-separated) +cat old-urls.txt | TWIST_AUTH_TOKEN="$(tw auth token view)" tdc migrate urls + +# --twist-token overrides the env var (visible in process lists — prefer the env var) +tdc migrate urls "https://twist.com/a/1/ch/2/t/3" --twist-token + +tdc migrate urls "" --json # Structured output ({ oldUrl, newUrl } / { oldUrl, error }) +tdc migrate urls "" --ndjson # Newline-delimited JSON +\`\`\` + +Output is one line per URL in input order: \`old -> new\` on success, \`old ✗ \` on failure (\`invalid_url\` / \`not_imported\`, or \`API_ERROR\` when the endpoint gives no code). Per-URL failures don't abort the run; the command exits non-zero if any URL fails to migrate. + ## Global Options \`\`\`bash