@tempoxyz/example-id-cli 0.0.0-stage → 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The CLI's saved sign-in: the user, their tokens, and the CLI's access key, in a file only the
3
+ * current user can read. `TEMPO_ID_CREDENTIALS` moves the file.
4
+ * @module
5
+ */
6
+ import * as Fs from 'node:fs/promises';
7
+ import * as Os from 'node:os';
8
+ import * as Path from 'node:path';
9
+ import { Key } from '@tempoxyz/id/client';
10
+ import { app, client } from './viem.config.js';
11
+ /** Where the sign-in is saved. */
12
+ const file = process.env.TEMPO_ID_CREDENTIALS ??
13
+ Path.join(Os.homedir(), '.config', 'tempo-id-playground', 'credentials.json');
14
+ /**
15
+ * Returns the saved sign-in, refreshing its access token when it is expired, and sets the client's
16
+ * account to its key. Returns `undefined` when signed out. A sign-in saved by an older CLI, whose
17
+ * key is expired or for another chain, or that another deployment or app issued counts as signed out, so its tokens
18
+ * never reach the deployment that `TEMPO_ID_URL` names, and `login` authorizes a new key.
19
+ */
20
+ export async function get() {
21
+ const saved = await Fs.readFile(file, 'utf8')
22
+ .then((text) => JSON.parse(text))
23
+ .catch(() => undefined);
24
+ // Earlier CLIs saved no `user`, or kept the tokens under `tokens`, so those count as signed out.
25
+ if (saved?.url !== new URL(app.url).origin ||
26
+ saved.clientId !== app.clientId ||
27
+ !saved.user ||
28
+ !saved.key?.keyAuthorization ||
29
+ // A key for another chain, such as one saved with another `TEMPO_RPC_URL`, cannot send here.
30
+ Number(saved.key.chainId) !== client.chain.id ||
31
+ !saved.accessToken ||
32
+ Key.expired(saved.key))
33
+ return undefined;
34
+ client.auth.restore(saved);
35
+ if (saved.expiresAt - 30_000 > Date.now())
36
+ return saved;
37
+ const tokens = await client.auth
38
+ .refresh({ refreshToken: saved.refreshToken })
39
+ .catch(() => undefined);
40
+ if (!tokens)
41
+ return undefined;
42
+ return save({ ...saved, ...tokens });
43
+ }
44
+ /**
45
+ * Revokes the saved refresh token at the revocation endpoint (RFC 7009) of the deployment that
46
+ * issued it, and forgets the sign-in. Returns whether the revocation succeeded. A failed one still
47
+ * forgets the sign-in.
48
+ */
49
+ export async function remove() {
50
+ const saved = await read();
51
+ const revoked = saved?.refreshToken ? await revoke(saved) : true;
52
+ await Fs.rm(file, { force: true });
53
+ return revoked;
54
+ }
55
+ /** Returns the saved sign-in as the file holds it, or `undefined` without a readable one. */
56
+ export async function read() {
57
+ return await Fs.readFile(file, 'utf8')
58
+ .then((text) => JSON.parse(text))
59
+ .catch(() => undefined);
60
+ }
61
+ /**
62
+ * Revokes the refresh token of `session` at the revocation endpoint (RFC 7009) of the deployment
63
+ * that issued it, and returns whether the revocation succeeded.
64
+ */
65
+ export async function revoke(session) {
66
+ // A sign-in saved by an older CLI names no deployment to revoke it at.
67
+ if (!session.refreshToken || !session.url || !session.clientId)
68
+ return false;
69
+ const response = await fetch(new URL('/oauth2/revoke', session.url), {
70
+ body: new URLSearchParams({
71
+ client_id: session.clientId,
72
+ token: session.refreshToken,
73
+ token_type_hint: 'refresh_token',
74
+ }),
75
+ method: 'POST',
76
+ signal: AbortSignal.timeout(10_000),
77
+ }).catch(() => undefined);
78
+ await response?.body?.cancel();
79
+ return response?.ok ?? false;
80
+ }
81
+ /**
82
+ * Saves a sign-in, readable only by the current user. The sign-in replaces the saved one in one
83
+ * rename, so a failed write leaves the earlier sign-in as it was.
84
+ */
85
+ export async function save(session) {
86
+ await Fs.mkdir(Path.dirname(file), { recursive: true });
87
+ const temporary = `${file}.${crypto.randomUUID()}.tmp`;
88
+ try {
89
+ await Fs.writeFile(temporary, JSON.stringify(session), {
90
+ flag: 'wx',
91
+ mode: 0o600,
92
+ });
93
+ await Fs.rename(temporary, file);
94
+ }
95
+ catch (error) {
96
+ await Fs.rm(temporary, { force: true });
97
+ throw error;
98
+ }
99
+ return session;
100
+ }
@@ -0,0 +1,17 @@
1
+ /** Funds the user's Tempo Account from the Tempo testnet faucet, including OUSD for fees. @module */
2
+ import { Cli } from 'incur';
3
+ import * as Session from '../Session.js';
4
+ import { client } from '../viem.config.js';
5
+ export default Cli.command({
6
+ description: 'Fund your Tempo Account with testnet tokens',
7
+ async run(c) {
8
+ const session = await Session.get();
9
+ if (!session)
10
+ return c.error({
11
+ code: 'SIGNED_OUT',
12
+ message: 'Not signed in. Run `login`.',
13
+ });
14
+ await client.faucet.fundSync({ account: client.account.address });
15
+ return { account: session.key.account, funded: true };
16
+ },
17
+ });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Signs in: shows a code to approve in any browser, waits for approval, and saves the tokens with
3
+ * the CLI's new access key and its authorization.
4
+ * @module
5
+ */
6
+ import { Permissions } from '@tempoxyz/id/client';
7
+ import { Cli, z } from 'incur';
8
+ import { Json, Value } from 'ox';
9
+ import { Expiry, Scopes } from 'viem/tempo';
10
+ import { tempoLocalnet, tempoTestnet } from 'viem/tempo/chains';
11
+ import * as Tokens from 'viem/tokens';
12
+ import * as Session from '../Session.js';
13
+ import { app, client, ousd } from '../viem.config.js';
14
+ export default Cli.command({
15
+ description: 'Sign in by approving a code in any browser',
16
+ options: z.object({
17
+ expiry: z
18
+ .string()
19
+ .regex(/^\d+[smhdw]?$/)
20
+ .optional()
21
+ .describe('How long the key lasts, such as 30m, 12h, 7d, or 2w, or a Unix time'),
22
+ limit: z
23
+ .array(z
24
+ .string()
25
+ .regex(/^\d+(\.\d+)?:[^:,/]+(\/\d+[smhdw]?)?(,\d+(\.\d+)?:[^:,/]+(\/\d+[smhdw]?)?)*$/))
26
+ .optional()
27
+ .describe('Spending limits, such as 100:OUSD, 100:OUSD/1d to reset daily, or 100:OUSD,50:EURC.e. Repeat or comma-separate for more'),
28
+ scope: z
29
+ .array(z.string().regex(/^\w+:[^:,]+(,\w+:[^:,]+)*$/))
30
+ .optional()
31
+ .describe('Token calls the key may make, such as transfer:OUSD,approve:OUSD. Repeat or comma-separate for more'),
32
+ }),
33
+ async *run(c) {
34
+ // The approval page shows the key's limits beside the machine's hostname. `PERMISSIONS` is ox's `Json`, which encodes
35
+ // bigints. Flags replace the fields they set, and Tempo ID fills what is left with the headless ceiling.
36
+ const base = Permissions.from(Json.parse(process.env.PERMISSIONS ?? '{}'));
37
+ // `fund` and `send` use the client's chain, so a key for another chain could not send.
38
+ if (base.chainId !== undefined && base.chainId !== client.chain.id)
39
+ return c.error({
40
+ code: 'CHAIN_MISMATCH',
41
+ message: `PERMISSIONS names chain ${base.chainId}, but the CLI sends on chain ${client.chain.id}. Remove chainId, or set it to ${client.chain.id}.`,
42
+ });
43
+ const device = await client.auth.device.authorize({
44
+ permissions: Permissions.from({
45
+ ...base,
46
+ ...(c.options.expiry ? { expiry: expiry(c.options.expiry) } : {}),
47
+ ...(c.options.limit
48
+ ? {
49
+ limits: c.options.limit.flatMap(list).map((value) => {
50
+ const [amount, rest] = value.split(':');
51
+ const [symbol, period] = rest.split('/');
52
+ const listing = token(symbol);
53
+ return {
54
+ limit: Value.from(amount, listing.decimals),
55
+ ...(period ? { period: seconds(period) } : {}),
56
+ token: listing.address,
57
+ };
58
+ }),
59
+ }
60
+ : {}),
61
+ ...(c.options.scope
62
+ ? {
63
+ scopes: c.options.scope.flatMap(list).map((value) => {
64
+ const [method, symbol] = value.split(':');
65
+ const calls = Scopes.tip20(token(symbol).address);
66
+ const scope = calls[method];
67
+ if (typeof scope !== 'function')
68
+ throw new Error(`Unknown TIP-20 call: ${method}.`);
69
+ return scope();
70
+ }),
71
+ }
72
+ : {}),
73
+ }),
74
+ });
75
+ yield `Open ${device.verificationUriComplete}`;
76
+ yield `and confirm the code ${device.userCode}.`;
77
+ const authorization = await device.wait().catch((error) => error);
78
+ if (authorization instanceof Error)
79
+ return c.error({ code: 'DENIED', message: authorization.message });
80
+ const { account: _, ...rest } = authorization;
81
+ const replaced = await Session.read();
82
+ // The new sign-in is saved first, so a failed write keeps the replaced one usable.
83
+ const session = await Session.save({
84
+ ...rest,
85
+ clientId: app.clientId,
86
+ url: new URL(app.url).origin,
87
+ });
88
+ if (replaced?.refreshToken)
89
+ await Session.revoke(replaced);
90
+ // A streaming command prints what it yields, not what it returns.
91
+ yield `Signed in as ${session.user.email}.`;
92
+ return c.ok({ email: session.user.email });
93
+ },
94
+ });
95
+ /** A flag's comma-separated entries. */
96
+ function list(value) {
97
+ return value.split(',');
98
+ }
99
+ /** A key's expiry from a duration such as `7d`, or a Unix time. */
100
+ function expiry(value) {
101
+ return /\d$/.test(value) ? Number(value) : Expiry.seconds(seconds(value));
102
+ }
103
+ /** A duration such as `30m`, `12h`, `7d`, or `2w` in seconds. A bare number is seconds. */
104
+ function seconds(value) {
105
+ const amount = Number.parseInt(value);
106
+ if (value.endsWith('m'))
107
+ return amount * 60;
108
+ if (value.endsWith('h'))
109
+ return amount * 3_600;
110
+ if (value.endsWith('d'))
111
+ return amount * 86_400;
112
+ if (value.endsWith('w'))
113
+ return amount * 604_800;
114
+ return amount;
115
+ }
116
+ /** A Tempo token on the client's chain, by symbol or address. */
117
+ function token(value) {
118
+ if (/^0x[0-9a-fA-F]{40}$/.test(value))
119
+ return { address: value, decimals: 6 };
120
+ const listing = Tokens.tokens.tempo.find((token) => token.symbol?.toLowerCase() === value.toLowerCase());
121
+ if (!listing)
122
+ throw new Error(`Unknown Tempo token: ${value}.`);
123
+ const addresses = listing.addresses;
124
+ // A local node predeploys the testnet's tokens, and OUSD has its mainnet address everywhere.
125
+ const address = addresses[client.chain.id] ??
126
+ (client.chain.id === tempoLocalnet.id
127
+ ? addresses[tempoTestnet.id]
128
+ : undefined) ??
129
+ (listing.symbol === 'OUSD' ? ousd : undefined);
130
+ if (!address)
131
+ throw new Error(`${listing.symbol} is not on chain ${client.chain.id}.`);
132
+ return { address, decimals: listing.decimals };
133
+ }
@@ -0,0 +1,10 @@
1
+ /** Revokes and forgets the saved tokens. @module */
2
+ import { Cli } from 'incur';
3
+ import * as Session from '../Session.js';
4
+ export default Cli.command({
5
+ description: 'Sign out on this machine',
6
+ async run() {
7
+ const revoked = await Session.remove();
8
+ return { revoked, signedOut: true };
9
+ },
10
+ });
@@ -0,0 +1,33 @@
1
+ /** Sends OUSD from the user's Tempo Account with the CLI's access key. @module */
2
+ import { Cli, z } from 'incur';
3
+ import { Value } from 'ox';
4
+ import * as Session from '../Session.js';
5
+ import { client, ousd } from '../viem.config.js';
6
+ export default Cli.command({
7
+ args: z.object({
8
+ to: z
9
+ .string()
10
+ .regex(/^0x[0-9a-fA-F]{40}$/)
11
+ .describe('Recipient address'),
12
+ amount: z
13
+ .string()
14
+ .regex(/^\d+(\.\d{1,6})?$/)
15
+ .describe('Amount of OUSD, such as 0.01'),
16
+ }),
17
+ description: 'Send OUSD from your Tempo Account',
18
+ async run(c) {
19
+ const session = await Session.get();
20
+ if (!session)
21
+ return c.error({
22
+ code: 'SIGNED_OUT',
23
+ message: 'Not signed in. Run `login`.',
24
+ });
25
+ // `Session.get` set the client's account to the session's key.
26
+ const { receipt } = await client.token.transferSync({
27
+ amount: Value.from(c.args.amount, 6),
28
+ to: c.args.to,
29
+ token: ousd,
30
+ });
31
+ return { status: receipt.status, transactionHash: receipt.transactionHash };
32
+ },
33
+ });
@@ -0,0 +1,20 @@
1
+ /** Prints the signed-in user's email and Tempo Account, refreshing the saved tokens when needed. @module */
2
+ import { Cli } from 'incur';
3
+ import * as Session from '../Session.js';
4
+ import { client } from '../viem.config.js';
5
+ export default Cli.command({
6
+ description: 'Show who is signed in',
7
+ async run(c) {
8
+ const session = await Session.get();
9
+ if (!session)
10
+ return c.error({
11
+ code: 'SIGNED_OUT',
12
+ message: 'Not signed in. Run `login`.',
13
+ });
14
+ // Tempo ID's UserInfo endpoint accepts the access token, as any Tempo API would.
15
+ const { email } = await client.auth.oauth.userInfo({
16
+ accessToken: session.accessToken,
17
+ });
18
+ return { account: session.key.account, email };
19
+ },
20
+ });
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * A CLI that signs in through Tempo ID's device grant, with an access key for the user's Tempo
4
+ * Account. Each command is a file in `commands/`, which incur loads by name.
5
+ *
6
+ * ```sh
7
+ * node index.ts login
8
+ * node index.ts login --limit 100:OUSD --limit 50:EURC.e --scope transfer:OUSD --expiry 7d
9
+ * node index.ts whoami
10
+ * node index.ts fund
11
+ * node index.ts send 0x000000000000000000000000000000000000dEaD 0.01
12
+ * node index.ts logout
13
+ * ```
14
+ * @module
15
+ */
16
+ import { Cli } from 'incur';
17
+ await Cli.create('tempo', {
18
+ description: "Signs in to Tempo through Tempo ID's device grant.",
19
+ })
20
+ .fs(new URL('./commands/', import.meta.url))
21
+ .serve();
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The Viem client the CLI shares, extended with Tempo ID and paying fees in OUSD: Tempo testnet,
3
+ * or a local Tempo node at `TEMPO_RPC_URL`, as the end-to-end tests run. `TEMPO_ID_URL` points it
4
+ * at a Tempo ID deployment, such as a pull request's preview, and `CLIENT_ID` names the device app
5
+ * that it signs in to. A sign-in or a saved session sets its account, so commands send without
6
+ * naming one.
7
+ * @module
8
+ */
9
+ import { Auth, tempoId } from '@tempoxyz/id/client';
10
+ import { http } from 'viem';
11
+ import { createClient } from 'viem/tempo';
12
+ import { tempo, tempoLocalnet, tempoTestnet } from 'viem/tempo/chains';
13
+ import * as Tokens from 'viem/tokens';
14
+ /** OUSD, which the CLI pays fees in and sends. It has its mainnet address on every Tempo chain. */
15
+ export const ousd = Tokens.ousd(tempo.id).address;
16
+ /**
17
+ * The Tempo ID deployment and the device app that the CLI signs in to. `scripts/seed.ts` registers
18
+ * the app. `tempo-cli-cash` signs in to Tempo Cash's realm.
19
+ */
20
+ export const app = {
21
+ clientId: process.env.CLIENT_ID ?? 'tempo-cli',
22
+ url: process.env.TEMPO_ID_URL ?? 'http://localhost:5173',
23
+ };
24
+ const rpcUrl = process.env.TEMPO_RPC_URL;
25
+ /** The CLI's Viem client, with Tempo ID's actions and the signed-in account. */
26
+ export const client = createClient({
27
+ account: Auth.account(),
28
+ chain: rpcUrl ? tempoLocalnet : tempoTestnet,
29
+ feeToken: ousd,
30
+ // Without a URL, the transport uses the chain's own RPC.
31
+ transport: http(rpcUrl),
32
+ }).extend(tempoId(app));
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "@tempoxyz/example-id-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.0",
4
+ "description": "Signs in to Tempo through Tempo ID's device grant.",
5
+ "type": "module",
6
+ "bin": {
7
+ "tempo": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/tempoxyz/id.git",
18
+ "directory": "playgrounds/cli"
19
+ },
20
+ "dependencies": {
21
+ "incur": "0.7.0",
22
+ "ox": "0.14.56",
23
+ "viem": "2.57.5",
24
+ "@tempoxyz/id": "0.0.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "26.6.4",
28
+ "tsx": "4.23.15",
29
+ "typescript": "7.0.2"
30
+ },
31
+ "scripts": {
32
+ "build": "tsc -p tsconfig.build.json",
33
+ "check:types": "tsc",
34
+ "start": "node --import tsx --env-file-if-exists=.env index.ts"
35
+ }
6
36
  }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.